alicorn

alicorn 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

alicorn 赞了文章 · 2019-04-02

DIY 一个 Vuex 持久化插件

在做 Vue 相关项目的时候,总会遇到因为页面刷新导致 Store 内容丢失的情况。复杂的项目往往涉及大量的状态需要管理,如果仅因为一次刷新就需要全部重新获取,代价也未免太大了。

那么我们能不能对这些状态进行本地的持久化呢?答案是可以的,社区里也提供了不少的解决方案,如 vuex-persistedstatevuex-localstorage 等插件,这些插件都提供了相对完善的功能。当然除了直接使用第三方插件以外,我们自己来 DIY 一个也是非常容易的。

这个持久化插件主要有2个功能:

  1. 能够选择需要被持久化的数据。
  2. 能够从本地读取持久化数据并更新至 Store。

接下来我们会从上述两个功能点出发,完成一个 Vuex 持久化插件。

Gist地址:https://gist.github.com/jrain...
在线体验地址:https://codepen.io/jrainlau/p...

一、学习写一个 Vuex 插件

引用 Vuex 官网 的例子:

Vuex 的 store 接受 plugins 选项,这个选项暴露出每次 mutation 的钩子。Vuex 插件就是一个函数,它接收 store 作为唯一参数:

const myPlugin = store => {
  // 当 store 初始化后调用
  store.subscribe((mutation, state) => {
    // 每次 mutation 之后调用
    // mutation 的格式为 { type, payload }
  })
}

然后像这样使用:

const store = new Vuex.Store({
  // ...
  plugins: [myPlugin]
})

一切如此简单,关键的一点就是在插件内部通过 store.subscribe() 来监听 mutation。在我们的持久化插件中,就是在这个函数内部对数据进行持久化操作。

二、允许用户选择需要被持久化的数据

首选初始化一个插件的主体函数:

const VuexLastingPlugin = function ({
  watch: '*',
  storageKey: 'VuexLastingData'
}) {
  return store => {}
}

插件当中的 watch 默认为全选符号 *,允许传入一个数组,数组的内容为需要被持久化的数据的 key 值,如 ['key1', 'key2'] 等。接着便可以去 store.subscribe() 里面对数据进行持久化操作了。

const VuexLastingPlugin = function ({
  watch: '*'
}) {
  return store => {
    store.subscribe((mutation, state) => {
      let watchedDatas = {}
      // 如果为全选,则持久化整个 state 
      // 否则将只持久化被列出的 state
      if (watch === '*') {
        watchedDatas = state
      } else {
        watch.forEach(key => {
          watchedDatas[key] = state[key]
        })
      }
      // 通过 localStorage 持久化
      localStorage && localStorage.setItem(storageKey, JSON.stringify(watchedDatas))
    })
  }
}

按照 Vuex 的规范,有且只有通过 mutation 才能够修改 state,于是按照上面的步骤,我们便完成了对数据进行实时持久化的工作。

这里也有一个小问题,就是写入 watch 参数的数组元素必须是 state 当中的最外层 key ,不支持形如 a.b.c 这样的嵌套 key。这样的功能显然不够完善,所以我们希望可以增加对嵌套 key 的支持。

新建一个工具函数 getObjDeepValue()

function getObjDeepValue (obj, keysArr) {
  let val = obj
  keysArr.forEach(key => {
    val = val[key]
  })
  return val
}

该函数接收一个对象和一个 key 值数组, 返回对应的值,我们来验证一下:

var obj = {
  a: {
    name: 'aaa',
    b: {
      name: 'bbb',
      c: {
        name: 'ccc'
      }
    }
  }
}

getObjDeepValue(obj, 'a.b.c'.split('.'))

// => { name: "ccc" }

验证成功以后,便可以把这个工具函数也放进 store.subscribe() 里使用了:

    store.subscribe((mutation, state) => {
      let watchedDatas = {}
      if (watch === '*') {
        watchedDatas = state
      } else {
        watch.forEach(key => {
          // 形如 a.b.c 这样的 key 会被保存为 deep_a.b.c 的形式
          if (data.split('.').length > 1) {
            watchedDatas[`deep_${key}`] = getObjDeepValue(state, key.split('.'))
          } else {
            watchedDatas[key] = state[key]
          }
        })
      }
      
      localStorage && localStorage.setItem(storageKey, JSON.stringify(watchedDatas))
    })

经过这一改造,通过 watch 写入的 key 值将支持嵌套的形式,整个插件将会更加灵活。

三、从本地读取持久化数据并更新至 Store

从上面的步骤我们已经能够灵活监听 store 里的数据并持久化它们了,接下来的工作就是完成如何在浏览器刷新之后去读取本地持久化数据,并把它们更新到 store。

为插件添加一个默认为 true 的选项 autoInit,作为是否自动读取并更新 store 的开关。从功能上来说,刷新浏览器之后插件应该自动读取 localStorage 里面所保存的数据,然后把它们更新到当前的 store。关键的点就是如何把 deep_${key} 的值正确赋值到对应的地方,所以我们需要再新建一个工具函数 setObjDeepValue()

function setObjDeepValue (obj, keysArr, value) {
  let key = keysArr.shift()
  if (keysArr.length) {
    setObjDeepValue(obj[key], keysArr, value)
  } else {
    obj[key] = value
  }
}

该函数接收一个对象,一个 key 值数组,和一个 value ,设置对象对应 key 的值,我们来验证一下:

var obj = {
  a: {
    name: 'aaa',
    b: {
      name: 'bbb',
      c: {
        name: 'ccc'
      }
    }
  }
}

setObjDeepValue(obj, ['a', 'b', 'c'], 12345)

/**
obj = {
  a: {
    name: 'aaa',
    b: {
      name: 'bbb',
      c: 12345
    }
  }
}
*/

有了这个工具方法,就可以正式操作 store 了。

    if (autoInit) {
      const localState = JSON.parse(storage && storage.getItem(storageKey))
      const storeState = store.state
      if (localState) {
        Object.keys(localState).forEach(key => {
          // 形如 deep_a.b.c 形式的值会被赋值到 state.a.b.c 中
          if (key.includes('deep_')) {
            let keysArr = key.replace('deep_', '').split('.')
            setObjDeepValue(storeState, keysArr, localState[key])
            delete localState[key]
          }
        })
        // 通过 Vuex 内置的 store.replaceState 方法修改 store.state
        store.replaceState({ ...storeState, ...localState })
      }
    }

上面这段代码会在页面初始化的时候读取 storage 的值,然后把形如 deep_a.b.c 的值提取并赋值到 store.state.a.b.c 当中,最后通过 store.replaceState() 方法更新整个 store.state 的值。这样便完成了从本地读取持久化数据并更新至 Store 的功能。

四、案例测试

我们可以写一个案例,来测试下这个插件的运行情况。

在线体验:https://codepen.io/jrainlau/p...

图片描述

App.vue

<template>
  <div id="app">
    <pre>{{$store.state}}</pre>

    <button @click="updateA">updateA</button>
    <button @click="updateX">UpdateX</button>
  </div>
</template>

<script>
export default {
  name: 'app',
  methods: {
    updateA () {
      let random = Math.random()
      this.$store.commit('updateA', {
        name: 'aaa' + random,
        b: {
          name: 'bbb' + random,
          c: {
            name: 'ccc' + random
          }
        }
      })
    },
    updateX () {
      this.$store.commit('updateX', { name: Math.random() })
    }
  }
}
</script>

store.js

import Vue from 'vue'
import Vuex from 'vuex'
import VuexPlugin from './vuexPlugin'

Vue.use(Vuex)

export default new Vuex.Store({
  plugins: [VuexPlugin({
    watch: ['a.b.c', 'x']
  })],
  state: {
    a: {
      name: 'aaa',
      b: {
        name: 'bbb',
        c: {
          name: 'ccc'
        }
      }
    },
    x: {
      name: 'xxx'
    }
  },
  mutations: {
    updateA (state, val) {
      state.a = val
    },
    updateX (state, val) {
      state.x = val
    }
  }
})

从案例可以看出,我们针对 state.a.b.c 和 state.x 进行了数据持久化。在整个 state.a 都被修改的情况下,仅仅只有 state.a.b.c 被存入了 localStorage ,数据恢复的时候也只修改了这个属性。而 state.x 则整个被监听,所以任何对于 state.x 的改动都会被持久化并能够被恢复。

尾声

这个 Vuex 插件仅在浏览器环境生效,未曾考虑到 SSR 的情况。有需要的同学可以在此基础上进行扩展,就不再展开讨论了。如果发现文章有任何错误或不完善的地方,欢迎留言和我一同探讨。

查看原文

赞 24 收藏 17 评论 1

alicorn 赞了文章 · 2018-09-25

RxJS:冷热模式的比较

Hot vs Cold Observables

理解冷热两种模式下的Observables对于掌握Observables来说至关重要,在我们开始探索这个话题之前,我们先来读一读RxJS官方的定义:

Cold Observables在被订阅后运行,也就是说,observables序列仅在subscribe函数被调用后才会推送数据。与Hot Observables不同之处在于,Hot Observables在被订阅之前就已经开始产生数据,例如mouse move事件。

OK,目前为止还不错,让我们来举几个例子。

首先我们以一个简单的只会发射数字1Observable开始。我们为这个Observable添加两个订阅并打印数据:

let obs = Rx.Observable.create(observer => observer.next(1));

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

运行结果如下:

1st subscriber: 1
2nd subscriber: 1

现在的问题是,代码中的obs是冷模式还是热模式?假设我们不知道obs是如何创建的,而是通过调用某个getObservableFromSomewhere()获得的,那么我们将无法得知obs是哪种模式。因此对于一般的订阅者来说,并不是总能知道所处理的是哪一种Observable

现在回想下上面引用的官方定义,如果obs是冷模式,那么它被订阅后才会产生“新鲜”的数据。为了体现“新鲜”这一点,我们用Date.now()来替代数字1,看看会发生什么。重新运行上面的代码我们得到:

1st subscriber: 1465990942935
2nd subscriber: 1465990942936

我们注意到两次获得的数据并不相同,这意味着observer.next(Date.now())一定被调用了两次。换句话说,obs在每次订阅后开始产生数据,这使得它成为冷模式下的Observable

将Cold Observables转化为Hot Observable

现在我们知道obs是冷模式,那么给它加加温会如何?

let obs = Rx.Observable
            .create(observer => observer.next(Date.now()))
            .publish();

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

obs.connect();

先不要管publishconnect这两个操作符是什么,我们来看下上面代码的输出:

1st subscriber: 1465994477014
2nd subscriber: 1465994477014

显然observer.next(Date.now())只被调用了一次(因为只产生了一次数据),那么obs已经是热模式了?Emmm,不完全是。我们可以这么说:现在的obs比冷模式下要“暖”,但是比热模式还要“凉”一些。说它暖是因为在不同的订阅里仅仅产生了一次数据,说它凉是因为它并没有在被订阅之前就开始产生数据。

我们可以稍微改动一下,将obs.connect()放到到所有订阅之前:

let obs = Rx.Observable
            .create(observer => observer.next(Date.now()))
            .publish();
obs.connect();

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

然而,当我们运行这段代码时却没有任何输出。其实这是意料之中的,因为现在的obs已经是热模式,它会在创建后立即开始产生数据而不管有没有被订阅过。所以在我们添加两个订阅之前,obs已经完成了最初始(也是唯一一次)的推送,因此随后的订阅不会收到新的数据,也就不会被触发。

为了理解publish的作用,我们来创建一个可以持续产生数据的Observable

let obs = Rx.Observable
            .interval(1000)
            .publish()
            .refCount();

obs.subscribe(v => console.log("1st subscriber:" + v));
setTimeout(()
  // delay for a little more than a second and then add second subscriber
  => obs.subscribe(v => console.log("2nd subscriber:" + v)), 1100);

这比之前的稍微复杂一点,所以我们一步一步来分析:

  • 首先用inerval(1000)来创建一个每秒产生一个递增数字的Observable,以0开始。

  • 然后用publish操作符来使不同订阅者之间共享同一个生产环境(热模式的一个标志)。

  • 最后使第二个订阅延迟1秒。

稍后我们再解释refCount

运行上面代码得到:

1st subscriber:0
1st subscriber:1
2nd subscriber:1
1st subscriber:2
2nd subscriber:2
...

很显然我们可以看到第二个订阅者并没有从0开始,这是因为publish可以使不同订阅者共享同一个生产环境,第二个订阅者和第一个共享了环境,因此它不会获得之前产生的数据。

理解publishrefCountconnect

在上面的例子中,obs到底有多“热”?我们修改代码,使两次订阅都延迟2秒。如果obs足够“热”,那么我们应该看不到数字01,因为它们在被订阅之前就被发射出去了。

let obs = Rx.Observable
            .interval(1000)
            .publish()
            .refCount();

setTimeout(() => {
  // delay both subscriptions by 2 seconds
  obs.subscribe(v => console.log("1st subscriber:" + v));
  setTimeout(
    // delay for a little more than a second and then add second subscriber
    () => obs.subscribe(
          v => console.log("2nd subscriber:" + v)), 1100);

},2000);

然而有意思的是,我们得到了和修改前完全一样的输出。这意味着我们的obs更应该被称为“暖”模式而不是“热”模式。这是因为refCount在起作用。publish操作符创建了一个被称为ConnectableObservable的序列,该序列对于数据源共享同一个订阅。然而此时publish操作符还没有订阅到数据源。它更像一个守门员,保证所有的订阅都订阅到ConnectableObservable上面,而不是数据源本身。

connect操作符使ConnectableObservable实际订阅到数据源。在上面的例子中,我们用refCount来代替connectrefCount是建立在connect之上的,它可以使ConnectableObservable在有第一个订阅者时订阅到数据源,并在没有订阅者时解除对数据源的订阅。这实际上对ConnectableObservable的所有订阅进行了记录。

如果我们想obs变成真正的热模式,我们就需要手动调用connect

let obs = Rx.Observable
            .interval(1000)
            .publish();
obs.connect();

setTimeout(() => {
  obs.subscribe(v => console.log("1st subscriber:" + v));
  setTimeout(
    () => obs.subscribe(v => console.log("2nd subscriber:" + v)), 1000);

},2000);

输出如下,我们的两个订阅者都会得到2之后的数字。

1st subscriber:2
1st subscriber:3
2nd subscriber:3

此时的obs是完完全全的热模式,它会在创建后立即产生数据而不管有没有订阅者。

如何使用

根据上面的讨论,我们知道很难说一个Observable是完全冷的还是热的。实际上我们还有一些推送数据给订阅者的方法没有讨论。通常来说,当我们处理一些会自动产生数据而不管有没有人监听的数据源时,我们应该使用热模式。当我们订阅这样的Observable时是得不到数据源发射的历史数据,而只能获得未来产生的值。

一个典型的例子就是mouse move事件。mouse move不管有没有人监听它都会发生。当我们开始监听它时,我们只能获得未来产生的mouse move事件。

冷模式下的Observable属于懒加载。只有当有人订阅时才会产生数据。但是别忘了,这不是绝对的。在之前的例子中我们知道,一个完全冷模式的Observable对于每个订阅者都会独立的重新生成数据。但是,一个仅在首次订阅后才开始产生数据并使之后的订阅者共享同一个生产环境发送同样数据的Observable,我们又该如何称呼呢?事情变得模棱两可,简单的将Observable分为冷热是不够的。

不管怎样,这儿有一条经验:当你有一个冷模式的Observable而又想不同的订阅者订阅它时获得之前产生过的数据时,你可以使用publish和它的小伙伴们。

注意:Http下的Observables

Angular2中的Http服务会返回一个冷模式的Observable,这里我们需要注意一下其潜在的行为。

首先我们来创建一个组件,该组件会从服务器获取contacts.json文件并渲染为页面上的一个列表。

...
@Component({
  ...
  template: `
    <ul>
      <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
    </ul>
    `
  ...
})
export class AppComponent {
  contacts: Observable<Array<any>>;
  constructor (http: Http) {
    this.contacts = http.get('contacts.json')
                        .map(response => response.json().items);
  }
}

在上面的例子中,我们将一个Observable<Array<any>>传递到模板中并使用AsyncPipe

contacts.json文件如下所示:

{
  "items": [
    { "name": "John Conner" },
    { "name": "Arnold Schwarzenegger" }
  ]
}

现在,如果我们再添加一个列表会怎样?

1st List
<ul>
  <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
</ul>
2st List
<ul>
  <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
</ul>

运行上述代码并观察浏览器控制台的网络面板,我们会发现浏览器对contacts.json发送了两次请求,这与我们用Promise的情况大相径庭。实际上,为我们的Observable添加toPromise操作符就可以消除第二次请求。

不过我们先别急着回到Promise。用我们之前学到的冷热模式,我们可以很轻松的将例子中的Observable转化成对所有订阅都共享同一生产环境的Observable,这里的环境就是HTTP调用。

this.contacts = http.get('contacts.json')
                    .map(response => response.json().items)
                    .publish()
                    .refCount();

这样就达到了我们想要的效果吗?不完全是。我们可以看到此时已经没有了第二次网络请求,但是这与使用Promise的情况还存在一些区别。

假如我们希望第二个列表延迟500ms再出现,修改代码如下:

@Component({
  ...
  template: `
    1st List
    <ul>
      <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
    </ul>
    2st List
    <ul>
      <li *ngFor="let contact of contacts2 | async">{{contact.name}}</li>
    </ul>
    `,
  ...
})
export class AppComponent {
  contacts: Observable<Array<any>>;
  contacts2: Observable<Array<any>>;
  constructor (http: Http) {
    this.contacts = http.get('contacts.json')
                        .map(response => response.json().items)
                        .publish()
                        .refCount();

    setTimeout(() => this.contacts2 = this.contacts, 500);
  }
}

注意到我们的Observable还是只有一个,并在500ms后将其赋值给contacts2。运行上面代码后,我们发现第二个列表并没有出现。

如果你仔细想想,这其实是符合逻辑的。当使用publish后我们将例子中的Observable变为共享的 - 即热模式,但是我们有点加热过了度。500ms后添加第二个订阅者时,它只会在有新的数据推送时才会触发,我们期望的是新订阅者能够获得之前发射过的数据。幸运的是我们可以用publishLast替代publish来实现这一需求。

this.contacts = http.get('contacts.json')
                    .map(response => response.json().items)
                    .publishLast()
                    .refCount();

现在运行上面的代码,我们能在500ms后看到列表并且没有发生第二次网络请求。换句话说,我们创建了一个能够在被订阅后触发并共享第一次发射的数据的Observable

实用技巧

publishrefCount是一个很常用的组合,而操作符share可以同时完成这两步。

this.contacts = http.get('contacts.json')
  .map(response => response.json().items)
  .share();

Demos

查看原文

赞 16 收藏 16 评论 2

alicorn 赞了文章 · 2018-09-25

RxJS:冷热模式的比较

Hot vs Cold Observables

理解冷热两种模式下的Observables对于掌握Observables来说至关重要,在我们开始探索这个话题之前,我们先来读一读RxJS官方的定义:

Cold Observables在被订阅后运行,也就是说,observables序列仅在subscribe函数被调用后才会推送数据。与Hot Observables不同之处在于,Hot Observables在被订阅之前就已经开始产生数据,例如mouse move事件。

OK,目前为止还不错,让我们来举几个例子。

首先我们以一个简单的只会发射数字1Observable开始。我们为这个Observable添加两个订阅并打印数据:

let obs = Rx.Observable.create(observer => observer.next(1));

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

运行结果如下:

1st subscriber: 1
2nd subscriber: 1

现在的问题是,代码中的obs是冷模式还是热模式?假设我们不知道obs是如何创建的,而是通过调用某个getObservableFromSomewhere()获得的,那么我们将无法得知obs是哪种模式。因此对于一般的订阅者来说,并不是总能知道所处理的是哪一种Observable

现在回想下上面引用的官方定义,如果obs是冷模式,那么它被订阅后才会产生“新鲜”的数据。为了体现“新鲜”这一点,我们用Date.now()来替代数字1,看看会发生什么。重新运行上面的代码我们得到:

1st subscriber: 1465990942935
2nd subscriber: 1465990942936

我们注意到两次获得的数据并不相同,这意味着observer.next(Date.now())一定被调用了两次。换句话说,obs在每次订阅后开始产生数据,这使得它成为冷模式下的Observable

将Cold Observables转化为Hot Observable

现在我们知道obs是冷模式,那么给它加加温会如何?

let obs = Rx.Observable
            .create(observer => observer.next(Date.now()))
            .publish();

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

obs.connect();

先不要管publishconnect这两个操作符是什么,我们来看下上面代码的输出:

1st subscriber: 1465994477014
2nd subscriber: 1465994477014

显然observer.next(Date.now())只被调用了一次(因为只产生了一次数据),那么obs已经是热模式了?Emmm,不完全是。我们可以这么说:现在的obs比冷模式下要“暖”,但是比热模式还要“凉”一些。说它暖是因为在不同的订阅里仅仅产生了一次数据,说它凉是因为它并没有在被订阅之前就开始产生数据。

我们可以稍微改动一下,将obs.connect()放到到所有订阅之前:

let obs = Rx.Observable
            .create(observer => observer.next(Date.now()))
            .publish();
obs.connect();

obs.subscribe(v => console.log("1st subscriber: " + v));
obs.subscribe(v => console.log("2nd subscriber: " + v));

然而,当我们运行这段代码时却没有任何输出。其实这是意料之中的,因为现在的obs已经是热模式,它会在创建后立即开始产生数据而不管有没有被订阅过。所以在我们添加两个订阅之前,obs已经完成了最初始(也是唯一一次)的推送,因此随后的订阅不会收到新的数据,也就不会被触发。

为了理解publish的作用,我们来创建一个可以持续产生数据的Observable

let obs = Rx.Observable
            .interval(1000)
            .publish()
            .refCount();

obs.subscribe(v => console.log("1st subscriber:" + v));
setTimeout(()
  // delay for a little more than a second and then add second subscriber
  => obs.subscribe(v => console.log("2nd subscriber:" + v)), 1100);

这比之前的稍微复杂一点,所以我们一步一步来分析:

  • 首先用inerval(1000)来创建一个每秒产生一个递增数字的Observable,以0开始。

  • 然后用publish操作符来使不同订阅者之间共享同一个生产环境(热模式的一个标志)。

  • 最后使第二个订阅延迟1秒。

稍后我们再解释refCount

运行上面代码得到:

1st subscriber:0
1st subscriber:1
2nd subscriber:1
1st subscriber:2
2nd subscriber:2
...

很显然我们可以看到第二个订阅者并没有从0开始,这是因为publish可以使不同订阅者共享同一个生产环境,第二个订阅者和第一个共享了环境,因此它不会获得之前产生的数据。

理解publishrefCountconnect

在上面的例子中,obs到底有多“热”?我们修改代码,使两次订阅都延迟2秒。如果obs足够“热”,那么我们应该看不到数字01,因为它们在被订阅之前就被发射出去了。

let obs = Rx.Observable
            .interval(1000)
            .publish()
            .refCount();

setTimeout(() => {
  // delay both subscriptions by 2 seconds
  obs.subscribe(v => console.log("1st subscriber:" + v));
  setTimeout(
    // delay for a little more than a second and then add second subscriber
    () => obs.subscribe(
          v => console.log("2nd subscriber:" + v)), 1100);

},2000);

然而有意思的是,我们得到了和修改前完全一样的输出。这意味着我们的obs更应该被称为“暖”模式而不是“热”模式。这是因为refCount在起作用。publish操作符创建了一个被称为ConnectableObservable的序列,该序列对于数据源共享同一个订阅。然而此时publish操作符还没有订阅到数据源。它更像一个守门员,保证所有的订阅都订阅到ConnectableObservable上面,而不是数据源本身。

connect操作符使ConnectableObservable实际订阅到数据源。在上面的例子中,我们用refCount来代替connectrefCount是建立在connect之上的,它可以使ConnectableObservable在有第一个订阅者时订阅到数据源,并在没有订阅者时解除对数据源的订阅。这实际上对ConnectableObservable的所有订阅进行了记录。

如果我们想obs变成真正的热模式,我们就需要手动调用connect

let obs = Rx.Observable
            .interval(1000)
            .publish();
obs.connect();

setTimeout(() => {
  obs.subscribe(v => console.log("1st subscriber:" + v));
  setTimeout(
    () => obs.subscribe(v => console.log("2nd subscriber:" + v)), 1000);

},2000);

输出如下,我们的两个订阅者都会得到2之后的数字。

1st subscriber:2
1st subscriber:3
2nd subscriber:3

此时的obs是完完全全的热模式,它会在创建后立即产生数据而不管有没有订阅者。

如何使用

根据上面的讨论,我们知道很难说一个Observable是完全冷的还是热的。实际上我们还有一些推送数据给订阅者的方法没有讨论。通常来说,当我们处理一些会自动产生数据而不管有没有人监听的数据源时,我们应该使用热模式。当我们订阅这样的Observable时是得不到数据源发射的历史数据,而只能获得未来产生的值。

一个典型的例子就是mouse move事件。mouse move不管有没有人监听它都会发生。当我们开始监听它时,我们只能获得未来产生的mouse move事件。

冷模式下的Observable属于懒加载。只有当有人订阅时才会产生数据。但是别忘了,这不是绝对的。在之前的例子中我们知道,一个完全冷模式的Observable对于每个订阅者都会独立的重新生成数据。但是,一个仅在首次订阅后才开始产生数据并使之后的订阅者共享同一个生产环境发送同样数据的Observable,我们又该如何称呼呢?事情变得模棱两可,简单的将Observable分为冷热是不够的。

不管怎样,这儿有一条经验:当你有一个冷模式的Observable而又想不同的订阅者订阅它时获得之前产生过的数据时,你可以使用publish和它的小伙伴们。

注意:Http下的Observables

Angular2中的Http服务会返回一个冷模式的Observable,这里我们需要注意一下其潜在的行为。

首先我们来创建一个组件,该组件会从服务器获取contacts.json文件并渲染为页面上的一个列表。

...
@Component({
  ...
  template: `
    <ul>
      <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
    </ul>
    `
  ...
})
export class AppComponent {
  contacts: Observable<Array<any>>;
  constructor (http: Http) {
    this.contacts = http.get('contacts.json')
                        .map(response => response.json().items);
  }
}

在上面的例子中,我们将一个Observable<Array<any>>传递到模板中并使用AsyncPipe

contacts.json文件如下所示:

{
  "items": [
    { "name": "John Conner" },
    { "name": "Arnold Schwarzenegger" }
  ]
}

现在,如果我们再添加一个列表会怎样?

1st List
<ul>
  <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
</ul>
2st List
<ul>
  <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
</ul>

运行上述代码并观察浏览器控制台的网络面板,我们会发现浏览器对contacts.json发送了两次请求,这与我们用Promise的情况大相径庭。实际上,为我们的Observable添加toPromise操作符就可以消除第二次请求。

不过我们先别急着回到Promise。用我们之前学到的冷热模式,我们可以很轻松的将例子中的Observable转化成对所有订阅都共享同一生产环境的Observable,这里的环境就是HTTP调用。

this.contacts = http.get('contacts.json')
                    .map(response => response.json().items)
                    .publish()
                    .refCount();

这样就达到了我们想要的效果吗?不完全是。我们可以看到此时已经没有了第二次网络请求,但是这与使用Promise的情况还存在一些区别。

假如我们希望第二个列表延迟500ms再出现,修改代码如下:

@Component({
  ...
  template: `
    1st List
    <ul>
      <li *ngFor="let contact of contacts | async">{{contact.name}}</li>
    </ul>
    2st List
    <ul>
      <li *ngFor="let contact of contacts2 | async">{{contact.name}}</li>
    </ul>
    `,
  ...
})
export class AppComponent {
  contacts: Observable<Array<any>>;
  contacts2: Observable<Array<any>>;
  constructor (http: Http) {
    this.contacts = http.get('contacts.json')
                        .map(response => response.json().items)
                        .publish()
                        .refCount();

    setTimeout(() => this.contacts2 = this.contacts, 500);
  }
}

注意到我们的Observable还是只有一个,并在500ms后将其赋值给contacts2。运行上面代码后,我们发现第二个列表并没有出现。

如果你仔细想想,这其实是符合逻辑的。当使用publish后我们将例子中的Observable变为共享的 - 即热模式,但是我们有点加热过了度。500ms后添加第二个订阅者时,它只会在有新的数据推送时才会触发,我们期望的是新订阅者能够获得之前发射过的数据。幸运的是我们可以用publishLast替代publish来实现这一需求。

this.contacts = http.get('contacts.json')
                    .map(response => response.json().items)
                    .publishLast()
                    .refCount();

现在运行上面的代码,我们能在500ms后看到列表并且没有发生第二次网络请求。换句话说,我们创建了一个能够在被订阅后触发并共享第一次发射的数据的Observable

实用技巧

publishrefCount是一个很常用的组合,而操作符share可以同时完成这两步。

this.contacts = http.get('contacts.json')
  .map(response => response.json().items)
  .share();

Demos

查看原文

赞 16 收藏 16 评论 2

alicorn 收藏了文章 · 2018-07-12

With TypeScript 2.8+ :更好的 React 组件开发模式

近两年来一直在关注 React 开发,最近也开始全面应用 TypeScript 。国内有很多讲解 React 和 TypeScript 的教程,但如何将 TypeScript 更好地应用到 React 组件开发模式的文章却几乎没有(也可能是我没找到),特别是 TS 的一些新特性,如:条件类型、条件类型中的类型引用等。这些新特性如何应用到 React 组件开发?没办法只能去翻一些国外的文章,结合 TS 的官方文档慢慢摸索... 于是就有了想法把这个过程整理成文档。

本文内容很长,希望你有个舒服的椅子,我们马上开始。

所有代码均使用 React 16.3、TypeScript 2.9 + strict mode 编写
全部示例代码都在这里

开始

本文假设你已经对 React、TypeScript 有一定的了解。我不会讲到例如:webpack 打包、Babel 转码、TypeScript 编译选项这一类的问题,而将一切焦点放在如何将 TS 2.8+ 更好地应用到 React 组件设计模式中。

首先,我们从无状态组件开始。

无状态组件

无状态组件就是没有 state 的,通常我们也叫做纯函数组件。用原生 JS 我们可以这样写一个按钮组件:

import React from 'react';

const Button = ({onClick: handleClick, children}) => (
  <button onClick={handleClick}>{children}</button>
);

如果你把代码直接放到 .tsx 文件中,tsc 编译器马上会提示错误:有隐含的 any 类型,因为用了严格模式。我们必须明确的定义组件属性,修改一下:

import React, { MouseEvent, ReactNode } from 'react';

interface Props { 
 onClick(e: MouseEvent<HTMLElement>): void;
 children?: ReactNode;
};

const Button = ({ onClick: handleClick, children }: Props) => (
  <button onClick={handleClick}>{children}</button>
);

OK,错误没有了!好像已经完事了?其实再花点心思可以做的更好。

React 中有个预定义的类型,SFC

type SFC<P = {}> = StatelessComponent<P>;

他是 StatelessComponent 的一个别名,而 StatelessComponent 声明了纯函数组件的一些预定义示例属性和静态属性,如:childrendefaultPropsdisplayName 等,所以我们不需要自己写所有的东西!

最后我们的代码是这样的:

图片描述

有状态的类组件

接着我们来创建一个计数器按钮组件。首先我们定义初始状态:

const initialState = {count: 0};

然后,定义一个别名 State 并用 TS 推断出类型:

type State = Readonly<typeof initialState>;
知识点:这样做不用分开维护接口声明和实现代码,比较实用的技巧

同时应该注意到,我们将所有的状态属性声明为 readonly 。然后我们需要明确定义 state 为组件的实例属性:

readonly state: State = initialState;

为什么要这样做?我们知道在 React 中我们不能直接改变 State 的属性值或者 State 本身:

this.state.count = 1; 
this.state = {count: 1};

如果这样做在运行时将会抛出错误,但在编写代码时却不会。所以我们需要明确的声明 readonly ,这样 TS 会让我们知道如果执行了这种操作就会出错了:

图片描述

下面是完整的代码:

这个组件不需要外部传递任何 Props ,所以泛型的第一个参数给的是不带任何属性的对象

图片描述

属性默认值

让我们来扩展一下纯函数按钮组件,加上一个颜色属性:

interface Props {
    onClick(e: MouseEvent<HTMLElement>): void;
    color: string;
}

如果想要定义属性默认值的话,我们知道可以通过 Button.defaultProps = {...} 做到。并且我们需要把这个属性声明为可选属性:(注意属性名后的 ?

interface Props {
    onClick(e: MouseEvent<HTMLElement>): void;
    color?: string;
}

那么组件现在看起来是这样的:

const Button: SFC<Props> = ({onClick: handleClick, color, children}) => (
    <button style={{color}} onClick={handleClick}>{children}</button>
);

一切看起来好像都很简单,但是这里有一个“痛点”。注意我们使用了 TS 的严格模式,color?: string 这个可选属性的类型现在是联合类型 -- string | undefined

这意味着什么?如果你要对这种属性进行一些操作,比如:substr() ,TS 编译器会直接报错,因为类型有可能是 undefined ,TS 并不知道属性默认值会由 Component.defaultProps 来创建。

碰到这种情况我们一般用两种方式来解决:

  • 使用类型断言手动去除,添加 ! 后缀,像这样:color!.substr(...)
  • 使用条件判断或者三元操作符让 TS 编译器知道这个属性不是 undefined,比如: if (color) ...

以上的方式虽然可以工作但有种多此一举的感觉,毕竟默认值已经有了只是 TS 编译器“不知道”而已。下面来说一种可重用的方案:我们写一个 withDefaultProps 函数,利用 TS 2.8 的条件类型映射,可以很简单的完成:

图片描述

这里涉及到两个 type 定义,写在 src/types/global.d.ts 文件里面:

declare type DiffPropertyNames<T extends string | number | symbol, U> =
    { [P in T]: P extends U ? never: P }[T];

declare type Omit<T, K> = Pick<T, DiffPropertyNames<keyof T, K>>;

看一下 TS 2.8 的新特性说明 关于 Conditional Types 的说明,就知道这两个 type 的原理了。

注意 TS 2.9 的新变化:keyof T 的类型是 string | number | symbol 的结构子类型。

现在我们可以利用 withDefaultProps 函数来写一个有属性默认值的组件了:

图片描述

现在使用这个组件时默认值属性已经发生作用,是可选的;并且在组件内部使用这些默认值属性不用再手动断言了,这些默认值属性就是必填属性!感觉还不错对吧

withDefautProps 函数同样可以应用在 stateful 有状态的类组件上。

渲染回调模式

有一种重用组件逻辑的设计方式是:把组件的 children 写成渲染回调函数或者暴露一个 render 函数属性出来。我们将用这种思路来做一个折叠面板的场景应用。

首先我们先写一个 Toggleable 组件,完整的代码如下:

图片描述

下面我们来逐段解释下这段代码,首先先看到组件的属性声明相关部分:

type Props = Partial<{
    children: RenderCallback;
    render: RenderCallback;
}>;

type RenderCallback = (args: ToggleableRenderArgs) => React.ReactNode;

type ToggleableRenderArgs = {
    show: boolean;
    toggle: Toggleable['toggle'];
}

我们需要同时支持 childrenrender 函数属性,所以这两个要声明为可选的属性。注意这里用了 Partial 映射类型,这样就不需要每个手动 ? 操作符来声明可选了。

为了保持 DRY 原则(Don't repeat yourself ),我们还声明了 RenderCallback 类型。

最后,我们将这个回调函数的参数声明为一个独立的类型:ToggleableRenderArgs

注意我们使用了 TS 的查找类型lookup types ),这样 toggle 的类型将和类中定义的同名方法类型保持一致:

private toggle = (event: MouseEvent<HTMLElement>) => {
    this.setState(prevState => ({show: !prevState.show}));
};
同样是为了 DRY ,TS 非常给力!

接下来是 State 相关的:

const initialState = {show: false};
type State = Readonly<typeof initialState>;

这个没什么特别的,跟前面的例子一样。

剩下的部分就是 渲染回调 设计模式了,代码很好理解:

class Toggleable extends Component<Props, State> {

    // ...

    render() {
        const {children, render} = this.props;
        const {show} = this.state;
        const renderArgs = {show, toggle: this.toggle};

        if (render) {
            return render(renderArgs);
        } else if (isFunction(children)) {
            return children(renderArgs);
        } else {
            return null;
        }
    }

    // ...
}

现在我们可以将 children 作为一个渲染函数传递给 Toggleable 组件:

图片描述

或者将渲染函数传递给 render 属性:

图片描述

下面我们来完成折叠面板剩下的工作,先写一个 Panel 组件来重用 Toggleable 的逻辑:

图片描述

最后写一个 Collapse 组件来完成这个应用:

图片描述

这里我们不谈样式的事情,运行起来看看,跟期待的效果是否一致?

图片描述

这种方式对于需要扩展渲染内容时非常有用:Toggleable 组件并不知道也不关心具体的渲染内容,但他控制着显示状态逻辑!

组件注入模式

为了使组件逻辑更具伸缩性,下面我们来说说组件注入模式。

那么什么是组件注入模式呢?如果你用过 React-Router ,你已经使用过这种模式来定义路由了:

<Route path="/example" component={Example}/>

不同于渲染回调模式,我们使用 component 属性“注入”一个组件。为了演示这个模式是如何工作的,我们将重构折叠面板这个场景,首先写一个可重用的 PanelItem 组件:

import { ToggleableComponentProps } from './Toggleable';

type PanelItemProps = { title: string };

const PanelItem: SFC<PanelItemProps & ToggleableComponentProps> = props => {
    const {title, children, show, toggle} = props;

    return (
        <div onClick={toggle}>
            <h1>{title}</h1>
            {show ? children : null}
        </div>
    );
};

然后重构 Toggleable 组件:加入新的 component 属性。对比先头的代码,我们需要做出如下变化:

  • children 属性类型更改为 function 或者 ReactNode(当使用 component 属性时)
  • component 属性将传递一个组件注入进去,这个注入组件的属性定义上需要有 ToggleableComponentProps (其实是原来的 ToggleableRenderArgs ,还记得吗?)
  • 还需要定义一个 props 属性,这个属性将用来传递注入组件需要的属性值。我们会设置 props 可以拥有任意的属性,因为我们并不知道注入组件会有哪些属性,当然这样我们会丢失 TS 的严格类型检查...
const defaultInjectedProps = {props: {} as { [propName: string]: any }};
type DefaultInjectedProps = typeof defaultInjectedProps;
type Props = Partial<{
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<any>>
} & DefaultInjectedProps>;

下一步我们把原来的 ToggleableRenderArgs 修改为 ToggleableComponentProps ,允许将注入组件需要的属性通过 <Toggleable props={...}/> 这样来传递:

type ToggleableComponentProps<P extends object = object> = {
    show: boolean;
    toggle: Toggleable['toggle'];
} & P;

现在我们还需要重构一下 render 方法:

render() {
    const {component: InjectedComponent, children, render, props} = this.props;
    const {show} = this.state;
    const renderProps = {show, toggle: this.toggle};

    if (InjectedComponent) {
        return (
            <InjectedComponent {...props} {...renderProps}>
                {children}
            </InjectedComponent>
        );
    }

    if (render) {
        return render(renderProps);
    } else if (isFunction(children)) {
        return children(renderProps);
    } else {
        return null;
    }
}

我们已经完成了整个 Toggleable 组件的修改,下面是完整的代码:

图片描述

最后我们写一个 PanelViaInjection 组件来应用组件注入模式:

import React, { SFC } from 'react';
import { Toggleable } from './Toggleable';
import { PanelItemProps, PanelItem } from './PanelItem';

const PanelViaInjection: SFC<PanelItemProps> = ({title, children}) => (
    <Toggleable component={PanelItem} props={{title}}>
        {children}
    </Toggleable>
);
注意:props 属性没有类型安全检查,因为他被定义为了包含任意属性的可索引类型:
{ [propName: string]: any }

现在我们可以利用这种方式来重现折叠面板场景了:

class Collapse extends Component {

    render() {
        return (
            <div>
                <PanelViaInjection title="标题一"><p>内容1</p></PanelViaInjection>
                <PanelViaInjection title="标题二"><p>内容2</p></PanelViaInjection>
                <PanelViaInjection title="标题三"><p>内容3</p></PanelViaInjection>
            </div>
        );
    }
}

泛型组件

在组件注入模式的例子中,props 属性丢失了类型安全检查,我们如何去修复这个问题呢?估计你已经猜出来了,我们可以把 Toggleable 组件重构为泛型组件!

下来我们开始重构 Toggleable 组件。首先我们需要让 props 支持泛型:

type DefaultInjectedProps<P extends object = object> = { props: P };
const defaultInjectedProps: DefaultInjectedProps = {props: {}};
                          
type Props<P extends object = object> = Partial<{
    children: RenderCallback | ReactNode;
    render: RenderCallback;
    component: ComponentType<ToggleableComponentProps<P>>
} & DefaultInjectedProps<P>>;

然后让 Toggleable 的 class 也支持泛型:

class Toggleable<T extends object = object> extends Component<Props<T>, State> {}

看起来好像已经搞定了!如果你是用的 TS 2.9,可以直接这样用:

const PanelViaInjection: SFC<PanelItemProps> = ({title, children}) => (
     <Toggleable<PanelItemProps> component={PanelItem} props={{title}}>
         {children}
     </Toggleable>
);

但是如果 <= TS 2.8 ... JSX 里面不能直接应用泛型参数 那么我们还有一步工作要做,加入一个静态方法 ofType ,用来进行构造函数的类型转换:

static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>;
}

这里用到一个 type:Constructor,依然定义在 src/types/global.d.ts 里面:

declare type Constructor<T = {}> = { new(...args: any[]): T };

好了,我们完成了所有的工作,下面是 Toggleable 重构后的完整代码:

现在我们来看看怎么使用这个泛型组件,重构下原来的 PanelViaInjection 组件:

import React, { SFC } from 'react';
import { Toggleable } from './Toggleable';
import { PanelItemProps, PanelItem } from './PanelItem';

const ToggleableOfPanelItem = Toggleable.ofType<PanelItemProps>();

const PanelViaInjection: SFC<PanelItemProps> = ({title, children}) => (
    <ToggleableOfPanelItem component={PanelItem} props={{title}}>
        {children}
    </ToggleableOfPanelItem>
);

所有的功能都能像原来的代码一样工作,并且现在 props 属性也支持 TS 类型检查了,很棒有木有!

图片描述

高阶组件

最后我们来看下 HOC 。前面我们已经实现了 Toggleable 的渲染回调模式,那么很自然的我们可以衍生出一个 HOC 组件。

如果对 HOC 不熟悉的话,可以先看下 React 官方文档对于 HOC 的说明。

先来看看定义 HOC 我们需要做哪些工作:

  • displayName (方便在 devtools 里面进行调试)
  • WrappedComponent (可以访问原始的组件 -- 有利于调试)
  • 引入 hoist-non-react-statics 包,将原始组件的静态方法全部“复制”到 HOC 组件上

下面直接上代码 -- withToggleable 高阶组件:

图片描述

现在我们来用 HOC 重写一个 Panel :

import { PanelItem } from './PanelItem';
import withToggleable from './withToggleable';

const PanelViaHOC = withToggleable(PanelItem);

然后,又可以实现折叠面板了

class Collapse extends Component {

    render() {
        return (
            <div>
                <PanelViaHOC title="标题一"><p>内容1</p></PanelViaHOC>
                <PanelViaHOC title="标题二"><p>内容2</p></PanelViaHOC>
            </div>
        );
    }
}

尾声

感谢能坚持看完的朋友,你们真的很棒!
所有的示例代码都在 这里 ,如果觉得还不错帮忙给个 star

最后,感谢 Anders Hejlsberg 和所有的 TS 贡献者

查看原文

alicorn 收藏了文章 · 2018-07-12

React + TS 2.8:终极组件设计模式指南

原文:Ultimate React Component Patterns with Typescript 2.8, Martin Hochel

本文的写作灵感来自于《React Component Patterns》,线上演示地址>>点我>>

熟悉我的朋友都知道,我不喜欢写无类型支持的 JavaScript,所以从 TypeScript 0.9 开始我就深深地爱上它了。
除了类型化的 JavaScript,我也非常喜欢 React,React 和 TypeScript 的结合让我感觉置身天堂:D。
在整个应用中,类型安全和 VDOM 的无缝衔接,让开发体验变得妙不可言!

所以本文想要分享什么信息呢?
尽管网上有很多关于 React 组件设计模式的文章,但是没有一篇介绍如何使用 TypeScript 来实现。
与此同时,最新版的 TypeScript 2.8 也带来了令人激动人心的功能,比如支持条件类型(Conditional Types)、标准库中预定义的条件类型以及同态映射类型修饰符等等,这些功能使我们能够更简便地写出类型安全的通用组件模式。

本文非常长,但是请不要被吓到了,因为我会手把手教你掌握终极 React 组件设计模式!

文中所有的设计模式和例子都使用 TypeScript 2.8 和严格模式

准备

磨刀不误砍柴工。首先我们要安装好 typescripttslib,使用 tslib 可以让我们生成的代码更加紧凑。

yarn add -D typescript
# tslib 弥补编译目标不支持的功能,如
yarn add tslib

然后,就可以使用 tsc 命令来初始化项目的 TypeScript 配置了。

# 为项目创建 tsconfig.json ,使用默认编译设置
yarn tsc --init

接着,安装 reactreact-dom 和它们的类型文件。

yarn add react react-dom
yarn add -D @types/{react,react-dom}

非常棒!现在我们就可以开始研究组件模式了,你准备好了么?

无状态组件

无状态组件(Stateless Component)就是没有状态(state)的组件。大多数时候,它们就是纯函数
下面让我们来使用 TypeScript 随便编写一个无状态的按钮组件。

就像使用纯 JavaScript 一样,我们需要引入 react 以支持 JSX 。
(译注:TypeScript 中,要支持 JSX,文件拓展名必须为 .tsx

import React from 'react'
const Button = ({ onClick: handleClick, children }) => (
  <button onClick={handleClick}>{children}</button>
)

不过 tsc 编译器报错了:(。我们需要明确地告诉组件它的属性是什么类型。所以,让我们来定义组件属性:

import React, { MouseEvent, ReactNode } from 'react'
type Props = { 
 onClick(e: MouseEvent<HTMLElement>): void
 children?: ReactNode 
}
const Button = ({ onClick: handleClick, children }: Props) => (
  <button onClick={handleClick}>{children}</button>
)

很好!这下终于没有报错了!但是我们还可以做得更好!

@types/react 类型模块中预定了 type SFC<P>,它是 interface StatelessComponent<P> 的类型别名,并且它预定义了 childrendisplayNamedefaultProps 等属性。所以,我们用不着自己写,可以直接拿来用。

于是,最终的代码长这样:

Stateless Component

状态组件

让我们来创建一个有状态的计数组件,并在其中使用我们上面创建的 Button 组件。

首先,定义好初始状态 initialState

const initialState = { clicksCount: 0 }

这样我们就可以使用 TypeScript 来对它进行类型推断了。

这种做法可以让我们不用分别独立维护类型和实现,如果实现变更了类型也会随之自动改变,妙!

type State = Readonly<typeof initialState>

同时,这里也明确地把所有属性都标记为只读。在使用的时候,我们还需要显式地把状态定义为只读,并声明为 State 类型。

readonly state: State = initialState

为什么声明为只读呢?
这是因为 React 不允许直接更新 state 及其属性。类似下面的做法是错误的

this.state.clicksCount = 2
this.state = { clicksCount: 2 }

该做法在编译时不会出错,但是会导致运行时错误。通过使用 Readonly 显式地把类型 type State 的属性都标记为只读属性,以及声明 state 为只读对象,TypeScript 可以实时地把错误用法反馈给开发者,从而避免错误。

比如:

Compile time State type safety

由于容器组件 ButtonCounter 还没有任何属性,所以我们把 Component 的第一个泛型参数组件属性类型设置为 object,因为 props 属性在 React 中总是 {}。第二个泛型参数是组件状态类型,所以这里使用我们前面定义的 State 类型。

Stateful Component

你可能已经注意到,在上面的代码中,我们把组件更新函数独立成了组件类外部的纯函数。这是一种常用的模式,这样的话我们就可以在不需要了解任何组件内部细节的情况下,单独对这些更新函数进行测试。此外,由于我们使用了 TypeScript ,而且已经把组件状态设置为只读,所以在这种纯函数中对状态的修改也会被及时发现。

const decrementClicksCount = (prevState: State) 
                      => ({ clicksCount: prevState.clicksCount-- })

// Will throw following complile error:
//
// [ts]
// Cannot assign to 'clicksCount' because it is a constant or a read-only property.

是不是很酷呢?;)

默认属性

现在让我们来拓展一下 Button 组件,给它添加一个 string 类型的 color 属性。

type Props = { 
    onClick(e: MouseEvent<HTMLElement>): void
    color: string 
}

如果想给组件设置默认属性,我们可以使用 Button.defaultProps = {...} 实现。这样的话,就需要把类型 Propscolor 标记为可选属性。像下面这样(多了一个问号):

type Props = { 
    onClick(e: MouseEvent<HTMLElement>): void
    color?: string 
}

此时,Button 组件就变成了下面的模样:

const Button: SFC<Props> = ({ onClick: handleClick, color, children }) => (
    <button style={{ color }} onClick={handleClick}>
        {children}
    </button>
)

这种实现方式工作起来是没毛病的,但是却存在隐患。因为我们是在严格模式下,所以可选属性 color 的类型其实是联合类型 undefined | string

假如后续我们需要用到 color,那么 TypeScript 就会抛出错误,因为编译器并不知道 color 已经被定义在 Component.defaultProps 了。

Default Props issue

为了告诉 TypeScript 编译器 color 已经被定义了,有以下 3 种办法:

  • 使用! 操作符(Bang Operator)显式地告诉编译器它的值不为空,像这样 <button onClick={handleClick!}>{children}</button>
  • 使用三元操作符(Ternary Operator)告诉编译器值它的值不为空:<button onClick={handleClick ? handleClick: undefined}>{children}</button>
  • 创建一个可复用的高阶函数(High Order Function)withDefaultProps,该函数会更新我们的属性类型定义并且设置默认属性。是我见过的最纯粹的解决办法。

多亏了 TypeScript 2.8 新增的预定义条件类型,withDefaultProps 实现起来非常简单。

withDefaultProps High order function generic helper

注意: Omit 并没有成为 TypeScript 2.8 预定义的条件映射类型,因此需要自行实现: declare type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;

下面我们用它来解决上面的问题:

Define default props on our Button component

或者更简单的:

Define default props on inline with Component implementation

现在,Button 的组件属性已经定义好,可以被使用了。在类型定义上,默认属性也被标记为可选属性,但是在是现实上仍然是必选的。

{
  onClick(e: MouseEvent<HTMLElement>): void
  color?: string
}

button with default props

在使用方式上也是一模一样:

render(){
  return (
    <ButtonWithDefaultProps 
      onClick={this.handleIncrement}
    >
      Increment
    </ButtonWithDefaultProps>
  )
}

withDefaultProps 也能用在直接使用 class 定义的组件上,如下图所示:

inline class

这里多亏了 TS 的类结构源,我们不需要显式定义 Props 泛型类型

ButtonViaClass 组件的用法也还是保持一致:

render(){
  return (
    <ButtonViaClass
      onClick={this.handleIncrement}
    >
      Increment
    </ButtonViaClass>
  )
}

接下来我们会编写一个可展开的菜单组件,当点击组件时,它会显示子组件内容。我们会用多种不同的组件模式来实现它。

渲染回调/渲染属性模式

要想让一个组件变得可复用,最简单的办法是把组件子元素变成一个函数或者新增一个 render 属性。这也是渲染回调(Render Callback)又被称为子组件函数(Function as Child Component)的原因。

首先,让我们来实现一个拥有 render 属性的 Toggleable 组件:

toggleable component

存在不少疑惑?

让我们来一步一步看各个重要部分的实现:

const initialState = { show: false }
type State = Readonly<typeof initialState>

这个没什么新内容,就跟我们前文的例子一样,只是声明状态类型。

接下来我们需要定义组件属性。注意:这里我们使用映射类型 Partial 来把属性标记为可选,而不是使用 ? 操作符。

type Props = Partial<{
  children: RenderCallback
  render: RenderCallback
}>
type RenderCallback = (args: ToggleableComponentProps) => JSX.Element
type ToggleableComponentProps = { 
  show: State['show']
  toggle: Toggleable['toggle'] 
}

我们希望同时支持子组件函数和渲染回调函数,所以这里把它们都标记为可选的。为了避免重复造轮子,这里为渲染函数创建了 RenderCallback 类型:

type RenderCallback = (args: ToggleableComponentProps) => JSX.Element

其中,看起来可能令人疑惑的是类型 type ToggleableComponentProps

type ToggleableComponentProps = { 
  show: State['show']
  toggle: Toggleable['toggle'] 
}

这个其实是用到了 TS 的类型查询功能,这样的话我们就不需要重复定义类型了:

  • show: State['show']:使用在状态中已经定义的类型来为 show 声明类型
  • toggle: Toggleable['toggle']:通过类型推断和类结构获取方法类型。优雅而强大!

其他部分的实现是很直观的,标准的渲染属性/子组件函数模式:

export class Toggleable extends Component<Props, State> {
  // ...
  render() {
    const { children, render } = this.props
    const renderProps = { show: this.state.show, toggle: this.toggle }
    if (render) {
      return render(renderProps)
    }
    return isFunction(children) ? children(renderProps) : null
  }
  // ...
}

至此,我们就可以通过子组件函数来使用 Toggleable 组件了:

children as a function

或者给 render 属性传递渲染函数:

render prop

得益于强大的 TS ,我们在编码的时候还可以有代码提示和正确的类型检查:

soundness

如果我们想复用它,可以简单的创建一个新组件来使用它:

ToggleableMenu

这个全新的 ToggleableMenu 组件现在就可以用在菜单组件中了:

Menu Component

而且效果也正如我们所预期:

menu demo

这种方式非常适合用在需要改变渲染内容本身,而又不想使用状态的场景。因为我们把渲染逻辑移到了 ToggleableMenu 的子组件函数中,同时又把状态逻辑留在 Toggleable 组件中。

组件注入

为了让我们的组件更加灵活,我们还可以引入组件注入(Component Injection)模式。

何为组件注入模式?如果你熟悉 React-Router 的话,那么在定义路由的时候就是在使用这个模式:

<Route path="/foo" component={MyView} />

所以,除了传递 render/children 属性,我们还可以通过 component 属性来注入组件。为此,我们需要把行内渲染回调函数重构成可复用的无状态组件:

import { ToggleableComponentProps } from './toggleable'
type MenuItemProps = { title: string }
const MenuItem: SFC<MenuItemProps & ToggleableComponentProps> = ({
  title,
  toggle,
  show,
  children,
}) => (
  <>
    <div onClick={toggle}>
      <h1>{title}</h1>
    </div>
    {show ? children : null}
  </>
)

这样的话,ToggleableMenu 也需要重构下:

type Props = { title: string }
const ToggleableMenu: SFC<Props> = ({ title, children }) => (
  <Toggleable
    render={({ show, toggle }) => (
      <MenuItem show={show} toggle={toggle} title={title}>
        {children}
      </MenuItem>
    )}
  />
)

接下来,让我们来定义新的 component 属性。
首先,我们需要更新下属性成员:

  • children 可以是函数或者是 ReactNode
  • component 是新成员,它的值为组件,该组件的属性需要实现 ToggleableComponentProps,同时它又必须支持默认为 any 的泛型类型,这样它不会仅仅用于实现了 ToggleableComponentProps 属性的组件。
  • props 是新成员,用来往下传递任意属性,这也是一种通用模式。它被定义为类型是 any 的索引类型,所以这里我们其实丢失了严格的安全检查。
// 使用任意属性类型来声明默认属性,props 默认为空对象
const defaultProps = { props: {} as { [name: string]: any } }
type Props = Partial<
  {
    children: RenderCallback | ReactNode
    render: RenderCallback
    component: ComponentType<ToggleableComponentProps<any>>
  } & DefaultProps
>
type DefaultProps = typeof defaultProps

接着,需要把新的 props 同步到 ToggleableComponentProps,这样才能使用 props 属性 <Toggleable props={...}/>

export type ToggleableComponentProps<P extends object = object> = {
  show: State['show']
  toggle: Toggleable['toggle']
} & P

最后还需要修改下 render 方法:

render() {
    const { 
     component: InjectedComponent, 
     children, 
     render, 
     props 
    } = this.props
    const renderProps = { 
     show: this.state.show, toggle: this.toggle 
    }
    // 当使用 component 属性时,children 不是一个函数而是 ReactNode
    if (InjectedComponent) {
      return (
        <InjectedComponent {...props} {...renderProps}>
          {children}
        </InjectedComponent>
      )
    }
    if (render) {
      return render(renderProps)
    }
    // children as a function comes last
    return isFunction(children) ? children(renderProps) : null
  }

把前面的内容都综合起来,就实现了一个支持 render 属性、函数子组件和组件注入的 Toggleable 组件:

full toggleable component

其使用方式如下:

ToggleableMenu with component injection pattern

这里要注意:我们自定义的 props 属性并没有安全的类型检查,因为它被定义为索引类型 { [name: string]: any }

We can pass anything to our props prop :(

在菜单组件的渲染中,ToggleableMenuViaComponentInjection 组件的使用方式跟原来一致:

export class Menu extends Component {
  render() {
    return (
      <>
        <ToggleableMenuViaComponentInjection title="First Menu">
          Some content
        </ToggleableMenuViaComponentInjection>
        <ToggleableMenuViaComponentInjection title="Second Menu">
          Another content
        </ToggleableMenuViaComponentInjection>
        <ToggleableMenuViaComponentInjection title="Third Menu">
          More content
        </ToggleableMenuViaComponentInjection>
      </>
    )
  }
}

泛型组件

在前面我们实现组件注入模式时,有一个大问题是 props 属性失去了严格的类型检查。如何解决这个问题?你可能已经猜到了!我们可以把 Toggleable 实现为泛型组件。

首先,我们需要把属性泛型化。我们可以使用默认泛型参数,这样的话,当我们不需要传 props 时就可以不用显式传递该参数了。

type Props<P extends object = object> = Partial<
  {
    children: RenderCallback | ReactNode
    render: RenderCallback
    component: ComponentType<ToggleableComponentProps<P>>
  } & DefaultProps<P>
>

此外,还需要使 ToggleableComponentProps 泛型化,不过它现在其实已经是了,所以这块不需要重写。

唯一需要改动的是 type DefaultProps ,因为目前的实现方式中,它是没有办法获取泛型类型的,所以我们需要把它改为另一种方式:

type DefaultProps<P extends object = object> = { props: P }
const defaultProps: DefaultProps = { props: {} }

马上就要完成了!

最后把 Toggleable 组件变成泛型组件。同样地,我们使用了默认参数,因为只有在使用组件注入时才需要传参,其他情况时则不需要。

export class Toggleable<T = {}> extends Component<Props<T>, State> {}

大功告成!不过,真的么?我们如何才能在 JSX 中使用泛型类型?

很遗憾,并不能。

所以,我们还需要引入 ofType 泛型组件工厂模式:

export class Toggleable<T extends object = object> extends Component<Props<T>, State> {
  static ofType<T extends object>() {
    return Toggleable as Constructor<Toggleable<T>>
  }
}

完整的实现版本如下:

generic props

有了 static ofType 静态方法之后,我们就可以创建正确的类型检查泛型组件了:

ofType

一切都跟之前一样,但是这次我们的 props 有了类型检查!

type safe

高阶组件

既然我们的 Toggleable 组件已经实现了 render 属性,那么实现高阶组件(High Order Component, HOC)就很容易了。渲染回调模式的最大好处之一就是,它可以直接用于实现 HOC。

下面让我们来实现这个 HOC。

我们需要新增以下内容:

  • displayName(用于调试工具展示,便于阅读)
  • WrappedComponent (用于访问原组件,便于测试)
  • 使用 hoist-non-react-statics 包的 hoistNonReactStatics 方法

hoc implemention

这样我们就可以以 HOC 的方式来创建 Toggleable 菜单项了, 而且仍然保持了对属性的类型检查。

const ToggleableMenuViaHOC = withToggleable(MenuItem)

Proper type annotation

受控组件

压轴大戏来了!
我们来实现一个可以通过父组件进行高度配置的 Toggleable ,这种是一种非常强大的模式。

可能有人会问,受控组件(Controlled Component)是什么?在这里意味着,我想要同时控制 Menu 组件中所有 ToggleableMenu 的内容是否显示,看看下面的动态你应该就知道是什么了。

controlled component

为了实现该目标,我们需要修改下 ToggleableMenu 组件,修改后的内容如下:

ToggleableMenu

然后,我们还需要在 Menu 中新增一个状态,并且把它传递给 ToggleableMenu

Stateful Menu component

最后,还需要修改 Toggleable 最后一次,让它变得更加无敌和灵活。
修改内容如下:

  1. 新增 show 属性到 Props
  2. 更新默认属性(因为 show 是可选的)
  3. 更新默认状态,使用属性 show 的值来初始化状态 show,因为我们希望该值只能来自于其父组件
  4. 使用 componentWillReceiveProps 来利用公开属性更新状态

1 & 2 对应的修改:

const initialState = { show: false }
const defaultProps: DefaultProps = { ...initialState, props: {} }

type State = Readonly<typeof initialState>
type DefaultProps<P extends object = object> = { props: P } & Pick<State, 'show'>

3 & 4 对应的修改:

export class Toggleable<T = {}> extends Component<Props<T>, State> {
  static readonly defaultProps: Props = defaultProps
  // Bang operator used, I know I know ...
  state: State = { show: this.props.show! }

  componentWillReceiveProps(nextProps: Props<T>) {
    const currentProps = this.props
    
    if (nextProps.show !== currentProps.show) {
      this.setState({ show: Boolean(nextProps.show) })
    }
  }
}

至此,终极 Toggleable 组件诞生了:

final Toggleable

同时,使用 ToggleablewithToggleable 也还要做些轻微调整,以便传递 show 属性和类型检查。

withToggleable Hoc with controllable functionality

总结

使用 TS 来实现对 React 组件进行正确的类型检查其实是相当难的。但是随着 TS 2.8 新功能的发布,我们几乎可以随意使用通用的 React 组件模式来实现类型安全的组件。

在本篇超长文中,多亏了 TS,我们学习了如何实现具有多种模式且类型安全的组件。

综合来看,其实最强大的模式非属性渲染(Render Prop)莫属,有了它,我们可以不费吹灰之力就可以实现组件注入和高阶组件。

文中所有的示范代码托管于作者的 GitHub 仓库

最后,还有一点要强调的是,本文中涉及的类型安全模板可能只适用于使用 VDOM/JSX 的库:

  • 使用语言服务的 Angular 模板也具备类型检查,但是在有些地方也还是会失效,比如 ngFor
  • Vue 模板目前也还没有类似 Angular ,所以它的模板和数据绑定实际上是魔术字符串。不过这可能在未来会改变。虽然也可以对模板字符串使用 VDOM,不过用起来应该会很笨重,因为有太多属性类型定义。(snabdom 表示:怪我咯)。
查看原文

alicorn 收藏了文章 · 2018-07-05

浅谈使用 Vue 构建前端 10w+ 代码量的单页面应用开发底层

开始之前

随着业务的不断累积,目前我们 ToC 端主要项目,除去 node_modulesbuild 配置文件dist 静态资源文件的代码量为 137521 行,后台管理系统下各个子应用代码,除去依赖等文件的总行数也达到 100万 多一点。

代码量意味不了什么,只能证明模块很多,但相同两个项目,在运行时性能相同情况下,你的 10 万行代码能容纳并维护 150 个模块,并且开发顺畅,我的项目中 10 万行代码却只能容纳 100 个模块,添加功能也好,维护起来也较为繁琐,这就很值得思考

本文会在主要描述以 Vue 技术栈技术主体ToC 端项目业务主体,在构建过程中,遇到或者总结的点(也会提及一些 ToB 项目的场景),可能并不适合你的业务场景(仅供参考),我会尽可能多的描述问题与其中的思考,最大可能的帮助到需要的同学,也辛苦开发者发现问题或者不合理/不正确的地方及时向我反馈,会尽快修改,欢迎有更好的实现方式来 pr

Git 地址
React 项目

可以参考蚂蚁金服数据体验技术团队编写的文章:

本文并不是基于上面文章写的,不过当时在看到他们文章之后觉得有相似的地方,相较于这篇文章,本文可能会枯燥些,会有大量代码,同学可以直接用上仓库看。

① 单页面,多页面

首先要思考我们的项目最终的构建主体单页面,还是多页面,还是单页 + 多页,通过他们的优缺点来分析:

  • 单页面(SPA)

    • 优点:体验好,路由之间跳转流程,可定制转场动画,使用了懒加载可有效减少首页白屏时间,相较于多页面减少了用户访问静态资源服务器的次数等。
    • 缺点:初始会加载较大的静态资源,并且随着业务增长会越来越大,懒加载也有他的弊端,不做特殊处理不利于 SEO 等。
  • 多页面(MPA)

    • 优点:对搜索引擎友好,开发难度较低。
    • 缺点:资源请求较多,整页刷新体验较差,页面间传递数据只能依赖 URLcookiestorage 等方式,较为局限。
  • SPA + MPA

    • 这种方式常见于较老 MPA 项目迁移至 SPA 的情况,缺点结合两者,两种主体通信方式也只能以兼容MPA 为准
    • 不过这种方式也有他的好处,假如你的 SPA 中,有类似文章分享这样(没有后端直出,后端返 HTML 串的情况下),想保证用户体验在 SPA 中开发一个页面,在 MPA 中也开发一个页面,去掉没用的依赖,或者直接用原生 JS 来开发,分享出去是 MPA 的文章页面,这样可以加快分享出去的打开速度,同时也能减少静态资源服务器的压力,因为如果分享出去的是 SPA 的文章页面,那 SPA 所需的静态资源至少都需要去进行协商请求,当然如果服务配置了强缓存就忽略以上所说。

我们首先根据业务所需,来最终确定构建主体,而我们选择了体验至上的 SPA,并选用 Vue 技术栈。

② 目录结构

其实我们看开源的绝大部分项目中,目录结构都会差不太多,我们可以综合一下来个通用的 src 目录:

src
├── assets          // 资源目录 图片,样式,iconfont
├── components      // 全局通用组件目录
├── config          // 项目配置,拦截器,开关
├── plugins         // 插件相关,生成路由、请求、store 等实例,并挂载 Vue 实例
├── directives      // 拓展指令集合
├── routes          // 路由配置
├── service         // 服务层
├── utils           // 工具类
└── views           // 视图层

③ 通用组件

components 中我们会存放 UI 组件库中的那些常见通用组件了,在项目中直接通过设置别名来使用,如果其他项目需要使用,就发到 npm 上。

结构

// components 简易结构
components
├── dist
├── build
├── src      
    ├── modal
    ├── toast
    └── ...
├── index.js             
└── package.json        

项目中使用

如果想最终编译成 es5,直接在 html 中使用或者部署 CDN 上,在 build 配置简单的打包逻辑,搭配着 package.json 构建 UI组件 的自动化打包发布,最终部署 dist 下的内容,并发布到 npm 上即可。

而我们也可直接使用 es6 的代码:

import 'Components/src/modal'

其他项目使用

假设我们发布的 npm 包bm-ui,并且下载到了本地 npm i bm-ui -S:

修改项目的最外层打包配置,在 rules 里 babel-loaderhappypack 中添加 includenode_modules/bm-ui

// webpack.base.conf
...
    rules: [{
        test: /\.vue$/,
        loader: 'vue-loader',
        options: vueLoaderConfig
    },
    {
        test: /\.js$/,
        loader: 'babel-loader',
        // 这里添加
        include: [resolve('src'), resolve('test'), resolve('node_modules/bm-ui')]
    },{
    ...
    }]
...

然后搭配着 babel-plugin-import 直接在项目中使用即可:

import { modal } from 'bm-ui'

多个组件库

同时有多个组件库的话,又或者有同学专门进行组件开发的话,把 `components
内部细分`一下,多一个文件分层。

components
├── bm-ui-1 
├── bm-ui-2
└── ...

你的打包配置文件可以放在 components 下,进行统一打包,当然如果要开源出去还是放在对应库下。

④ 全局配置,插件与拦截器

这个点其实会是项目中经常被忽略的,或者说很少聚合到一起,但同时我认为是整个项目中的重要之一,后续会有例子说道。

全局配置,拦截器目录结构

config
├── index.js             // 全局配置/开关
├── interceptors        // 拦截器
    ├── index.js        // 入口文件
    ├── axios.js        // 请求/响应拦截
    ├── router.js       // 路由拦截
    └── ...
└── ...

全局配置

我们在 config/index.js 可能会有如下配置:

// config/index.js

// 当前宿主平台 兼容多平台应该通过一些特定函数来取得
export const HOST_PLATFORM = 'WEB'
// 这个就不多说了
export const NODE_ENV = process.env.NODE_ENV || 'prod'

// 是否强制所有请求访问本地 MOCK,看到这里同学不难猜到,每个请求也可以单独控制是否请求 MOCK
export const AJAX_LOCALLY_ENABLE = false
// 是否开启监控
export const MONITOR_ENABLE = true
// 路由默认配置,路由表并不从此注入
export const ROUTER_DEFAULT_CONFIG = {
    waitForData: true,
    transitionOnLoad: true
}

// axios 默认配置
export const AXIOS_DEFAULT_CONFIG = {
    timeout: 20000,
    maxContentLength: 2000,
    headers: {}
}

// vuex 默认配置
export const VUEX_DEFAULT_CONFIG = {
    strict: process.env.NODE_ENV !== 'production'
}

// API 默认配置
export const API_DEFAULT_CONFIG = {
    mockBaseURL: '',
    mock: true,
    debug: false,
    sep: '/'
}

// CONST 默认配置
export const CONST_DEFAULT_CONFIG = {
    sep: '/'
}

// 还有一些业务相关的配置
// ...


// 还有一些方便开发的配置
export const CONSOLE_REQUEST_ENABLE = true      // 开启请求参数打印
export const CONSOLE_RESPONSE_ENABLE = true     // 开启响应参数打印
export const CONSOLE_MONITOR_ENABLE = true      // 监控记录打印

可以看出这里汇集了项目中所有用到的配置,下面我们在 plugins 中实例化插件,注入对应配置,目录如下:

插件目录结构

plugins
├── api.js              // 服务层 api 插件
├── axios.js            // 请求实例插件
├── const.js            // 服务层 const 插件
├── store.js            // vuex 实例插件
├── inject.js           // 注入 Vue 原型插件
└── router.js           // 路由实例插件

实例化插件并注入配置

这里先举出两个例子,看我们是如何注入配置,拦截器并实例化的

实例化 router

import Vue from 'vue'
import Router from 'vue-router'
import ROUTES from 'Routes'
import {ROUTER_DEFAULT_CONFIG} from 'Config/index'
import {routerBeforeEachFunc} from 'Config/interceptors/router'

Vue.use(Router)

// 注入默认配置和路由表
let routerInstance = new Router({
    ...ROUTER_DEFAULT_CONFIG,
    routes: ROUTES
})
// 注入拦截器
routerInstance.beforeEach(routerBeforeEachFunc)

export default routerInstance

实例化 axios

import axios from 'axios'
import {AXIOS_DEFAULT_CONFIG} from 'Config/index'
import {requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc} from 'Config/interceptors/axios'

let axiosInstance = {}

axiosInstance = axios.create(AXIOS_DEFAULT_CONFIG)

// 注入请求拦截
axiosInstance
    .interceptors.request.use(requestSuccessFunc, requestFailFunc)
// 注入响应拦截
axiosInstance
    .interceptors.response.use(responseSuccessFunc, responseFailFunc)

export default axiosInstance

我们在 main.js注入插件

// main.js
import Vue from 'vue'

GLOBAL.vbus = new Vue()

// import 'Components'// 全局组件注册
import 'Directives' // 指令

// 引入插件
import router from 'Plugins/router'
import inject from 'Plugins/inject'
import store from 'Plugins/store'
// 引入组件库及其组件库样式 
// 不需要配置的库就在这里引入 
// 如果需要配置都放入 plugin 即可
import VueOnsen from 'vue-onsenui'
import 'onsenui/css/onsenui.css'
import 'onsenui/css/onsen-css-components.css'
// 引入根组件
import App from './App'

Vue.use(inject)
Vue.use(VueOnsen)

// render
new Vue({
    el: '#app',
    router,
    store,
    template: '<App/>',
    components: { App }
})

axios 实例我们并没有直接引用,相信你也猜到他是通过 inject 插件引用的,我们看下 inject

import axios from './axios'
import api from './api'
import consts from './const'
GLOBAL.ajax = axios
 
export default {
    install: (Vue, options) => {
        Vue.prototype.$api = api
        Vue.prototype.$ajax = axios
        Vue.prototype.$const = consts
        // 需要挂载的都放在这里
    }
}

这里可以挂载你想在业务中( vue 实例中)便捷访问的 api,除了 $ajax 之外,apiconst 两个插件是我们服务层中主要的功能,后续会介绍,这样我们插件流程大致运转起来,下面写对应拦截器的方法。

请求,路由拦截器

ajax 拦截器中(config/interceptors/axios.js):

// config/interceptors/axios.js

import {CONSOLE_REQUEST_ENABLE, CONSOLE_RESPONSE_ENABLE} from '../index.js'

export function requestSuccessFunc (requestObj) {
    CONSOLE_REQUEST_ENABLE && console.info('requestInterceptorFunc', `url: ${requestObj.url}`, requestObj)
    // 自定义请求拦截逻辑,可以处理权限,请求发送监控等
    // ...
    
    return requestObj
}

export function requestFailFunc (requestError) {
    // 自定义发送请求失败逻辑,断网,请求发送监控等
    // ...
    
    return Promise.reject(requestError);
}

export function responseSuccessFunc (responseObj) {
    // 自定义响应成功逻辑,全局拦截接口,根据不同业务做不同处理,响应成功监控等
    // ...
    // 假设我们请求体为
    // {
    //     code: 1010,
    //     msg: 'this is a msg',
    //     data: null
    // }
    
    let resData =  responseObj.data
    let {code} = resData
    
    switch(code) {
        case 0: // 如果业务成功,直接进成功回调  
            return resData.data;
        case 1111: 
            // 如果业务失败,根据不同 code 做不同处理
            // 比如最常见的授权过期跳登录
            // 特定弹窗
            // 跳转特定页面等
            
            location.href = xxx // 这里的路径也可以放到全局配置里
            return;
        default:
            // 业务中还会有一些特殊 code 逻辑,我们可以在这里做统一处理,也可以下方它们到业务层
            !responseObj.config.noShowDefaultError && GLOBAL.vbus.$emit('global.$dialog.show', resData.msg);
            return Promise.reject(resData);
    }
}

export function responseFailFunc (responseError) {
    // 响应失败,可根据 responseError.message 和 responseError.response.status 来做监控处理
    // ...
    return Promise.reject(responseError);
}

定义路由拦截器(config/interceptors/router.js):

// config/interceptors/router.js

export function routerBeforeFunc (to, from, next) {
    // 这里可以做页面拦截,很多后台系统中也非常喜欢在这里面做权限处理
    
    // next(...)
}

最后在入口文件(config/interceptors/index.js)中引入并暴露出来即可:

import {requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc} from './ajax'
import {routerBeforeEachFunc} from './router'

let interceptors = {
    requestSuccessFunc,
    requestFailFunc,
    responseSuccessFunc,
    responseFailFunc,
    routerBeforeEachFunc
}

export default interceptors

请求拦截这里代码都很简单,对于 responseSuccessFunc 中 switch default 逻辑做下简单说明:

  1. responseObj.config.noShowDefaultError 这里可能不太好理解

我们在请求的时候,可以传入一个 axios 中并没有意义的 noShowDefaultError 参数为我们业务所用,当值为 false 或者不存在时,我们会触发全局事件 global.dialog.showglobal.dialog.show我们会注册在 app.vue 中:

// app.vue

export default {
    ...
    created() {
        this.bindEvents
    },
    methods: {
        bindEvents() {
            GLOBAL.vbus.$on('global.dialog.show', (msg) => {
                if(msg) return
                // 我们会在这里注册全局需要操控试图层的事件,方便在非业务代码中通过发布订阅调用
                this.$dialog.popup({
                    content: msg 
                });
            })
        }
        ...
    }
}
这里也可以把弹窗状态放入 Store 中,按团队喜好,我们习惯把公共的涉及视图逻辑的公共状态在这里注册,和业务区分开来
  1. GLOBAL 是我们挂载 window 上的全局对象,我们把需要挂载的东西都放在 window.GLOBAL 里,减少命名空间冲突的可能。
  2. vbus 其实就是我们开始 new Vue() 挂载上去的
GLOBAL.vbus = new Vue()
  1. 我们在这里 Promise.reject 出去,我们就可以在 error 回调里面只处理我们的业务逻辑,而其他如断网超时服务器出错等均通过拦截器进行统一处理。

拦截器处理前后对比

对比下处理前后在业务中的发送请求的代码

拦截器处理前

this.$axios.get('test_url').then(({code, data}) => {
    if( code === 0 ) {
        // 业务成功
    } else if () {}
        // em... 各种业务不成功处理,如果遇到通用的处理,还需要抽离出来
    
    
}, error => {
   // 需要根据 error 做各种抽离好的处理逻辑,断网,超时等等...
})

拦截器处理后

// 业务失败走默认弹窗逻辑的情况
this.$axios.get('test_url').then(({data}) => {
    // 业务成功,直接操作 data 即可
})

// 业务失败自定义
this.$axios.get('test_url', {
    noShowDefaultError: true // 可选
}).then(({data}) => {
    // 业务成功,直接操作 data 即可
    
}, (code, msg) => {
    // 当有特定 code 需要特殊处理,传入 noShowDefaultError:true,在这个回调处理就行
})

为什么要如此配置与拦截器?

在应对项目开发过程中需求的不可预见性时,让我们能处理的更快更好

到这里很多同学会觉得,就这么简单的引入判断,可有可无,
就如我们最近做的一个需求来说,我们 ToC 端项目之前一直是在微信公众号中打开的,而我们需要在小程序中通过 webview 打开大部分流程,而我们也没有时间,没有空间在小程序中重写近 100 + 的页面流程,这是我们开发之初并没有想到的。这时候必须把项目兼容到小程序端,在兼容过程中可能需要解决以下问题:

  1. 请求路径完全不同。
  2. 需要兼容两套不同的权限系统。
  3. 有些流程在小程序端需要做改动,跳转到特定页面。
  4. 有些公众号的 api ,在小程序中无用,需要调用小程序的逻辑,需要做兼容。
  5. 很多也页面上的元素,小程序端不做展示等。
可以看出,稍微不慎,会影响公众号现有逻辑。
  • 添加请求拦截 interceptors/minaAjax.jsinterceptors/minaRouter.js,原有的换更为 interceptors/officalAjax.jsinterceptors/officalRouter.js,在入口文件interceptors/index.js根据当前宿主平台,也就是全局配置 HOST_PLATFORM,通过代理模式策略模式,注入对应平台的拦截器minaAjax.js中重写请求路径和权限处理,在 minaRouter.js 中添加页面拦截配置,跳转到特定页面,这样一并解决了上面的问题 1,2,3
  • 问题 4 其实也比较好处理了,拷贝需要兼容 api 的页面,重写里面的逻辑,通过路由拦截器一并做跳转处理
  • 问题 5 也很简单,拓展两个自定义指令 v-mina-show 和 v-mina-hide ,在展示不同步的地方可以直接使用指令。

最终用最少的代码,最快的时间完美上线,丝毫没影响到现有 toC 端业务,而且这样把所有兼容逻辑绝大部分聚合到了一起,方便二次拓展和修改。

虽然这只是根据自身业务结合来说明,可能没什么说服力,不过不难看出全局配置/拦截器 虽然代码不多,但却是整个项目的核心之一,我们可以在里面做更多 awesome 的事情。

⑤ 路由配置与懒加载

directives 里面没什么可说的,不过很多难题都可以通过他来解决,要时刻记住,我们可以再指令里面操作虚拟 DOM。

路由配置

而我们根据自己的业务性质,最终根据业务流程来拆分配置:

routes
├── index.js            // 入口文件
├── common.js           // 公共路由,登录,提示页等
├── account.js          // 账户流程
├── register.js         // 挂号流程
└── ...

最终通过 index.js 暴露出去给 plugins/router 实例使用,这里的拆分配置有两个注意的地方:

  • 需要根据自己业务性质来决定,有的项目可能适合业务线划分,有的项目更适合以 功能 划分。
  • 在多人协作过程中,尽可能避免冲突,或者减少冲突。

懒加载

文章开头说到单页面静态资源过大,首次打开/每次版本升级后都会较慢,可以用懒加载来拆分静态资源,减少白屏时间,但开头也说到懒加载也有待商榷的地方:

  • 如果异步加载较多的组件,会给静态资源服务器/ CDN 带来更大的访问压力的同时,如果当多个异步组件都被修改,造成版本号的变动,发布的时候会大大增加 CDN 被击穿的风险。
  • 懒加载首次加载未被缓存的异步组件白屏的问题,造成用户体验不好。
  • 异步加载通用组件,会在页面可能会在网络延时的情况下参差不齐的展示出来等。

这就需要我们根据项目情况在空间和时间上做一些权衡。

以下几点可以作为简单的参考:

  • 对于访问量可控的项目,如公司后台管理系统中,可以以操作 view 为单位进行异步加载,通用组件全部同步加载的方式。
  • 对于一些复杂度较高,实时度较高的应用类型,可采用按功能模块拆分进行异步组件加载。
  • 如果项目想保证比较高的完整性和体验,迭代频率可控,不太关心首次加载时间的话,可按需使用异步加载或者直接不使用。
打包出来的 main.js 的大小,绝大部分都是在路由中引入的并注册的视图组件。

⑥ Service 服务层

服务层作为项目中的另一个核心之一,“自古以来”都是大家比较关心的地方。

不知道你是否看到过如下组织代码方式:

views/
    pay/
        index.vue
        service.js
        components/
            a.vue
            b.vue

service.js 中写入编写数据来源

export const CONFIAG = {
    apple: '苹果',
    banana: '香蕉'
}
// ...

// ① 处理业务逻辑,还弹窗
export function getBInfo ({name = '', id = ''}) {
    return this.$ajax.get('/api/info', {
        name, 
        id
    }).then({age} => {
        this.$modal.show({
            content: age
        })
    })
}

// ② 不处理业务,仅仅写请求方法
export function getAInfo ({name = '', id = ''}) {
    return this.$ajax.get('/api/info', {
        name, 
        id
    })
}

...

简单分析:

  • ① 就不多说了,拆分的不够单纯,当做二次开发的时候,你还得去找这弹窗到底哪里出来的。
  • ② 看起来很美好,不掺杂业务逻辑,但不知道你与没遇到过这样情况,经常会有其他业务需要用到一样的枚举,请求一样的接口,而开发其他业务的同学并不知道你在这里有一份数据源,最终造成的结果就是数据源的代码到处冗余

我相信②在绝大多数项目中都能看到。

那么我们的目的就很明显了,解决冗余,方便使用,我们把枚举和请求接口的方法,通过插件,挂载到一个大对象上,注入 Vue 原型,方面业务使用即可。

目录层级(仅供参考)

service
├── api
    ├── index.js             // 入口文件
    ├── order.js             // 订单相关接口配置
    └── ...
├── const                   
    ├── index.js             // 入口文件
    ├── order.js             // 订单常量接口配置
    └── ...
├── store                    // vuex 状态管理
├── expands                  // 拓展
    ├── monitor.js           // 监控
    ├── beacon.js            // 打点
    ├── localstorage.js      // 本地存储
    └── ...                  // 按需拓展
└── ...

抽离模型

首先抽离请求接口模型,可按照领域模型抽离 (service/api/index.js):

{
    user: [{
        name: 'info',
        method: 'GET',
        desc: '测试接口1',
        path: '/api/info',
        mockPath: '/api/info',
        params: {
            a: 1,
            b: 2
        }
    }, {
        name: 'info2',
        method: 'GET',
        desc: '测试接口2',
        path: '/api/info2',
        mockPath: '/api/info2',
        params: {
            a: 1,
            b: 2,
            b: 3
        }
    }],
    order: [{
        name: 'change',
        method: 'POST',
        desc: '订单变更',
        path: '/api/order/change',
        mockPath: '/api/order/change',
        params: {
            type: 'SUCCESS'
        }
    }]
    ...
}

定制下需要的几个功能:

  • 请求参数自动截取。
  • 请求参数不传,则发送默认配置参数。
  • 得需要命名空间。
  • 通过全局配置开启调试模式。
  • 通过全局配置来控制走本地 mock 还是线上接口等。

插件编写

定制好功能,开始编写简单的 plugins/api.js 插件:

import axios from './axios'
import _pick from 'lodash/pick'
import _assign from 'lodash/assign'
import _isEmpty from 'lodash/isEmpty'

import { assert } from 'Utils/tools'
import { API_DEFAULT_CONFIG } from 'Config'
import API_CONFIG from 'Service/api'


class MakeApi {
    constructor(options) {
        this.api = {}
        this.apiBuilder(options)
    }


    apiBuilder({
        sep = '|',
        config = {},
        mock = false, 
        debug = false,
        mockBaseURL = ''
    }) {
        Object.keys(config).map(namespace => {
            this._apiSingleBuilder({
                namespace, 
                mock, 
                mockBaseURL, 
                sep, 
                debug, 
                config: config[namespace]
            })
        })
    }
    _apiSingleBuilder({
        namespace, 
        sep = '|',
        config = {},
        mock = false, 
        debug = false,
        mockBaseURL = ''
    }) {
        config.forEach( api => {
            const {name, desc, params, method, path, mockPath } = api
            let apiname = `${namespace}${sep}${name}`,// 命名空间
                url = mock ? mockPath : path,//控制走 mock 还是线上
                baseURL = mock && mockBaseURL
            
            // 通过全局配置开启调试模式。
            debug && console.info(`调用服务层接口${apiname},接口描述为${desc}`)
            debug && assert(name, `${apiUrl} :接口name属性不能为空`)
            debug && assert(apiUrl.indexOf('/') === 0, `${apiUrl} :接口路径path,首字符应为/`)

            Object.defineProperty(this.api, `${namespace}${sep}${name}`, {
                value(outerParams, outerOptions) {
                
                    // 请求参数自动截取。
                    // 请求参数不穿则发送默认配置参数。
                    let _data = _isEmpty(outerParams) ? params : _pick(_assign({}, params, outerParams), Object.keys(params))
                    return axios(_normoalize(_assign({
                        url,
                        desc,
                        baseURL,
                        method
                    }, outerOptions), _data))
                }
            })      
        })
    }       
}

function _normoalize(options, data) {
    // 这里可以做大小写转换,也可以做其他类型 RESTFUl 的兼容
    if (options.method === 'POST') {
        options.data = data
    } else if (options.method === 'GET') {
        options.params = data
    }
    return options
} 
// 注入模型和全局配置,并暴露出去
export default new MakeApi({
    config: API_CONFIG,
    ...API_DEFAULT_CONFIG
})['api']

挂载到 Vue 原型上,上文有说到,通过 plugins/inject.js

import api from './api'
 
export default {
    install: (Vue, options) => {
        Vue.prototype.$api = api
        // 需要挂载的都放在这里
    }
}

使用

这样我们可以在业务中愉快的使用业务层代码:

// .vue 中
export default {
    methods: {
        test() {
            this.$api['order/info']({
                a: 1,
                b: 2
            })
        }
    }
}

即使在业务之外也可以使用:

import api from 'Plugins/api'

api['order/info']({
    a: 1,
    b: 2
})

当然对于运行效率要求高的项目中,避免内存使用率过大,我们需要改造 API,用解构的方式引入使用,最终利用 webpacktree-shaking 减少打包体积。几个简单的思路

一般来说,多人协作时候大家都可以先看 api 是否有对应接口,当业务量上来的时候,也肯定会有人出现找不到,或者找起来比较费劲,这时候我们完全可以在 请求拦截器中,把当前请求的 urlapi 中的请求做下判断,如果有重复接口请求路径,则提醒开发者已经配置相关请求,根据情况是否进行二次配置即可。

最终我们可以拓展 Service 层的各个功能:

基础

  • api异步与后端交互
  • const常量枚举
  • storeVuex 状态管理

拓展

  • localStorage:本地数据,稍微封装下,支持存取对象即可
  • monitor监控功能,自定义搜集策略,调用 api 中的接口发送
  • beacon打点功能,自定义搜集策略,调用 api 中的接口发送
  • ...

constlocalStoragemonitorbeacon 根据业务自行拓展暴露给业务使用即可,思想也是一样的,下面着重说下 store(Vuex)

插一句:如果看到这里没感觉不妥的话,想想上面 plugins/api.js 有没有用单例模式?该不该用?

⑦ 状态管理与视图拆分

Vuex 源码分析可以看我之前写的文章

我们是不是真的需要状态管理?

答案是否定的,就算你的项目达到 10 万行代码,那也并不意味着你必须使用 Vuex,应该由业务场景决定。

业务场景

  1. 第一类项目:业务/视图复杂度不高,不建议使用 Vuex,会带来开发与维护的成本,使用简单的 vbus 做好命名空间,来解耦即可。
let vbus = new Vue()
vbus.$on('print.hello', () => {
    console.log('hello')
})

vbus.$emit('print.hello')
  1. 第二类项目:类似多人协作项目管理有道云笔记网易云音乐微信网页版/桌面版应用,功能集中,空间利用率高,实时交互的项目,无疑 Vuex 是较好的选择。这类应用中我们可以直接抽离业务领域模型
store
├── index.js          
├── actions.js        // 根级别 action
├── mutations.js      // 根级别 mutation
└── modules
    ├── user.js       // 用户模块
    ├── products.js   // 产品模块
    ├── order.js      // 订单模块
    └── ...

当然对于这类项目,vuex 或许不是最好的选择,有兴趣的同学可以学习下 rxjs

  1. 第三类项目:后台系统或者页面之间业务耦合不高的项目,这类项目是占比应该是很大的,我们思考下这类项目:

全局共享状态不多,但是难免在某个模块中会有复杂度较高的功能(客服系统,实时聊天,多人协作功能等),这时候如果为了项目的可管理性,我们也在 store 中进行管理,随着项目的迭代我们不难遇到这样的情况:

store/
    ...
    modules/
        b.js
        ...
views/
    ...
    a/
        b.js
        ...
        
  • 试想下有几十个 module,对应这边上百个业务模块,开发者在两个平级目录之间调试与开发的成本是巨大的。
  • 这些 module 可以在项目中任一一个地方被访问,但往往他们都是冗余的,除了引用的功能模块之外,基本不会再有其他模块引用他。
  • 项目的可维护程度会随着项目增大而增大。

如何解决第三类项目的 store 使用问题?

先梳理我们的目标:

  • 项目中模块可以自定决定是否使用 Vuex。(渐进增强)
  • 从有状态管理的模块,跳转没有的模块,我们不想把之前的状态挂载到 store 上,想提高运行效率。(冗余)
  • 让这类项目的状态管理变的更加可维护。(开发成本/沟通成本)

实现

我们借助 Vuex 提供的 registerModuleunregisterModule 一并解决这些问题,我们在 service/store 中放入全局共享的状态:

service/
    store/
        index.js
        actions.js
        mutations.js
        getters.js
        state.js
一般这类项目全局状态不多,如果多了拆分 module 即可。

编写插件生成 store 实例

import Vue from 'vue'
import Vuex from 'vuex'
import {VUEX_DEFAULT_CONFIG} from 'Config'
import commonStore from 'Service/store'

Vue.use(Vuex)

export default new Vuex.Store({
    ...commonStore,
    ...VUEX_DEFAULT_CONFIG
})

对一个需要状态管理页面或者模块进行分层:

views/
    pageA/
        index.vue
        components/
            a.vue
            b.vue
            ...
        children/
            childrenA.vue
            childrenB.vue
            ...
        store/
            index.js
            actions.js
            moduleA.js  
            moduleB.js

module 中直接包含了 gettersmutationsstate,我们在 store/index.js 中做文章:

import Store from 'Plugins/store'
import actions from './actions.js'
import moduleA from './moduleA.js'
import moduleB from './moduleB.js'

export default {
    install() {
        Store.registerModule(['pageA'], {
            actions,
            modules: {
                moduleA,
                moduleB
            },
            namespaced: true
        })
    },
    uninstall() {
        Store.unregisterModule(['pageA'])
    }
}

最终在 index.vue 中引入使用, 在页面跳转之前注册这些状态和管理状态的规则,在路由离开之前,先卸载这些状态和管理状态的规则

import store from './store'
import {mapGetters} from 'vuex'
export default {
    computed: {
        ...mapGetters('pageA', ['aaa', 'bbb', 'ccc'])
    },
    beforeRouterEnter(to, from, next) {
        store.install()
        next()
    },
    beforeRouterLeave(to, from, next) {
        store.uninstall()
        next()
    }
}

当然如果你的状态要共享到全局,就不执行 uninstall

这样就解决了开头的三个问题,不同开发者在开发页面的时候,可以根据页面特性,渐进增强的选择某种开发形式。

其他

这里简单列举下其他方面,需要自行根据项目深入和使用。

打包,构建

这里网上已经有很多优化方法:dllhappypack多线程打包等,但随着项目的代码量级,每次 dev 保存的时候编译的速度也是会愈来愈慢的,而一过慢的时候我们就不得不进行拆分,这是肯定的,而在拆分之前尽可能容纳更多的可维护的代码,有几个可以尝试和规避的点:

  1. 优化项目流程:这个点看起来好像没什么用,但改变却是最直观的,页面/业务上的化简为繁会直接体现到代码上,同时也会增大项目的可维护,可拓展性等。
  2. 减少项目文件层级纵向深度。
  3. 减少无用业务代码,避免使用无用或者过大依赖(类似 moment.js 这样的库)等。

样式

  • 尽可能抽离各个模块,让整个样式底层更加灵活,同时也应该尽可能的减少冗余。
  • 如果使用的 sass 的话,善用 %placeholder 减少无用代码打包进来。
MPA 应用中样式冗余过大,%placeholder 也会给你带来帮助。

Mock

很多大公司都有自己的 mock 平台,当前后端定好接口格式,放入生成对应 mock api,如果没有 mock 平台,那就找相对好用的工具如 json-server 等。

代码规范

请强制使用 eslint,挂在 git 的钩子上。定期 diff 代码,定期培训等。

TypeScript

非常建议用 TS 编写项目,可能写 .vue 有些别扭,这样前端的大部分错误在编译时解决,同时也能提高浏览器运行时效率,可能减少 re-optimize 阶段时间等。

测试

这也是项目非常重要的一点,如果你的项目还未使用一些测试工具,请尽快接入,这里不过多赘述。

拆分系统

当项目到达到一定业务量级时,由于项目中的模块过多,新同学维护成本,开发成本都会直线上升,不得不拆分项目,后续会分享出来我们 ToB 项目在拆分系统中的简单实践。

最后

时下有各种成熟的方案,这里只是一个简单的构建分享,里面依赖的版本都是我们稳定下来的版本,需要根据自己实际情况进行升级。

项目底层构建往往会成为前端忽略的地方,我们既要从一个大局观来看待一个项目或者整条业务线,又要对每一行代码精益求精,对开发体验不断优化,慢慢累积后才能更好的应对未知的变化。

最后请允许我打一波小小的广告

EROS

如果前端同学想尝试使用 Vue 开发 App,或者熟悉 weex 开发的同学,可以来尝试使用我们的开源解决方案 eros,虽然没做过什么广告,但不完全统计,50 个在线 APP 还是有的,期待你的加入。

最后附上部分产品截图~

(逃~)

查看原文

alicorn 收藏了文章 · 2018-06-26

前端每日实战:24# 视频演示如何用纯 CSS 创作出平滑的层叠海浪特效

图片描述

效果预览

按下右侧的“点击预览”按钮可以在当前页面预览,点击链接可以全屏预览。

https://codepen.io/comehope/pen/JvmBdE

可交互视频教程

此视频是可以交互的,你可以随时暂停视频,编辑视频中的代码。

请用 chrome, safari, edge 打开观看。

https://scrimba.com/p/pEgDAM/cp2edUD

源代码下载

每日前端实战系列的全部源代码请从 github 下载:

https://github.com/comehope/front-end-daily-challenges

代码解读

定义 dom,容器中包含一行文本和3条做海浪特效的 <span>:

<div class="sea">
    <p class="title">the sea</p>
    <span class="wave"></span>
    <span class="wave"></span>
    <span class="wave"></span>
</div>

居中显示:

html, body {
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(antiquewhite, navajowhite);
}

设置容器样式:

.sea {
    width: 300px;
    height: 300px;
    background-color: whitesmoke;
    background-image: linear-gradient(
        darkblue,
        rgba(255, 255, 255, 0) 80%,
        rgba(255, 255, 255, 0.5));
    border-radius: 5px;
    box-shadow: 0 2px 30px rgba(0, 0, 0, 0.2);
}

设置文字样式:

.sea {
    position: relative;
}

.sea .title {
    color: white;
    font-size: 24px;
    font-family: serif;
    text-align: center;
    line-height: 250px;
    text-transform: uppercase;
    letter-spacing: 0.4em;
    position: absolute;
    z-index: 1;
    width: 100%;
}

制作海浪动画效果:

.sea .wave {
    position: absolute;
    top: -250px;
    left: -100px;
    width: 500px;
    height: 500px;
    background: deepskyblue;
    border-radius: 43%;
    filter: opacity(0.4);
    animation: drift linear infinite;
}

.sea .wave:nth-of-type(1) {
    animation-duration: 5s;
}

.sea .wave:nth-of-type(2) {
    animation-duration: 7s;
}

.sea .wave:nth-of-type(3) {
    animation-duration: 9s;
}

@keyframes drift {
    from {
        transform: rotate(360deg);
    }
}

加大海浪的波动幅度,增加颜色差异:

.sea .wave {
    transform-origin: 50% 48%;
}

.sea .wave:nth-of-type(3) {
    background-color: orangered;
    filter: opacity(0.1);
}

最后,隐藏容器外的内容:

.sea {
    overflow: hidden;
}

大功告成!

查看原文

alicorn 关注了专栏 · 2018-06-26

前端每日实战

?该专栏由《CSS3 艺术》一书的作者亲自维护,已累计分享 170+ 个前端项目从灵感闪现到代码实现的完整过程。?

关注 5288

alicorn 收藏了文章 · 2018-06-05

作为前端,你不得不知道的SEO

  研发的同学,其实很多人并没有深入了解SEO这个概念。在技术博客里,提及这一块的也寥寥无几。我今天就拿自己的经验,简单给大家扫个盲,有什么遗漏的地方,欢迎大家补充。

  文字内容有点多,但是干货满满,请耐心阅读!

什么是SEO:

  以下内容摘自百度百科:

SEO(Search Engine Optimization):汉译为搜索引擎优化。搜索引擎优化是一种利用搜索引擎的搜索规则来提高目前网站在有关搜索引擎内的自然排名的方式。SEO是指为了从搜索引擎中获得更多的免费流量,从网站结构、内容建设方案、用户互动传播、页面等角度进行合理规划,使网站更适合搜索引擎的索引原则的行为。

SEO原理

  其实搜索引擎做的工作是相当复杂的,我们这里简单说一下大致的过程。后续针对SEO如何优化,也会根据这几个点展开描述。

  • 页面抓取: 蜘蛛向服务器请求页面,获取页面内容
  • 分析入库:对获取到的内容进行分析,对优质页面进行收录
  • 检索排序:当用户检索关键词时,从收录的页面中按照一定的规则进行排序,并返回给用户结果

SEO优化

  既然是优化,那我们就得遵循SEO的原理来做,可谓知己知彼,百战不殆。针对上面提出的三点,我们分别展开叙述。我们这里主要以百度蜘蛛举例。

页面抓取

  如何才能吸引蜘蛛光顾我们的网站,如何才能让蜘蛛经常光顾我们的网站。这里提出以下几个优化点:

  1. 提交页面。提交页面又分为几种不同的方式

    1. sitemap提交。sitemap,顾名思义,就是网站地图,当蜘蛛来到我们的网站时,告诉它我们有多少页面,不同页面是按什么分类的,每个页面的地址是什么。顺着我们的指引,蜘蛛会很轻松的爬遍所有内容。另外,如果你的页面分类比较多,而且数量大,建议添加sitemap索引文件。如果站点经常更新添加新页面,建议及时更新sitemap文件;
    2. 主动提交。就是把你的页面直接丢给百度的接口,亲口告诉百度你有哪些页面,这是效率最高也是收录最快的方式了。但是需要注意,百度对每天提交的数量是有限制的,而且反复提交重复的页面,会被降低每日限额,所以已被收录的页面不建议反复提交。收录有个时间过程,请先耐心等待;
    3. 实时提交。在页面中安装百度给的提交代码,当这个页面被用户打开我,便自动把这个页面提交给百度。这里不需要考虑重复提交的问题。

以上几种提交方式可以同时使用,互不冲突。

  1. 保证我们的页面是蜘蛛可读的。

      早在ajax还没流行的的时候,其实SEO对于前端的要求并没有很多,或者说,那个时候还没有前端这个职业。页面全部在服务器端由渲染好,不管是用户还是蜘蛛过来,都能很友好的返回html。ajax似乎原本是为了避免有数据交互导致必须重刷页面设计的,但是被大规模滥用,一些开发者不管三七二十一,所有数据都用ajax请求,使得蜘蛛不能顺利的获取页面内容。庆幸的是这反倒促进了前端的飞速发展。

      到了后来,各种SPA单页应用框架的出现,使得前端开发者不再需要关心页面的DOM结构,只需专注业务逻辑,数据全部由Javascript发ajax请求获取数据,然后在客户端进行渲染。这也就导致了老生常谈的SEO问题。百度在国内搜索引擎的占有率最高,但是很不幸,它并不支持ajax数据的爬取。于是,开发者开始想别的解决方案,比如检测到是爬虫过来,单独把它转发到一个专门的路由去渲染,比如基于Node.js的Jade引擎(现在改名叫Pug了),就能很好地解决这个问题。React和Vue,包括一个比较小众的框架Marko也出了对应的服务端渲染解决方案。详细内容查看对应文档,我就不多说了。

  2. URL与301

  URL设置要合理规范,层次分明。如果网站到了后期发现URL不合理需要重新替换时,会导致之前收录的页面失效,就是我们所说的死链(这种情况属于死链的一种,404等也属于死链)。所以一定要在网站建设初期就做好长远的规划。一旦出现这种情况也不要过于担心,我们可以采取向搜索引擎投诉或者设置301跳转的方式解决。

  URL层级嵌套不要太深,建议不超过四层。增加面包屑导航可以使页面层次分明,也有利于为蜘蛛营造顺利的爬取路径。

  除此之外,将指向首页的域名全部设置301跳转到同一URL,可以避免分散权重。

分析入库

  当蜘蛛把页面抓取回去之后,就需要对页面内容进行分析,并择优收录入库。为什么说是择优呢?下面我给你慢慢分析。

  搜索引擎的目的是给用户提供高质量的、精准的搜索结果。如果整个页面充斥着满满的广告和各种不良信息,这无疑会很大程度上影响用户体验。

  除此之外,你肯定不希望自己辛辛苦苦创作的文章被别人轻而易举的抄走,所以搜索引擎在一定程度上帮助你避免这种情况的发生。对于已经收录的内容,搜索引擎会降低其权重,甚至直接不收录这个页面。即便是这样,为了保证页面的新鲜度,很多网站都会爬取或者转载其他网站的内容。这就是我们经常听到的伪原创。所以,想要让你的页面能够以较高的权重被收录,就需要坚持更新网站内容,并且是高质量的原创内容。
  

检索排序

  这块我的理解是,页面被收录后,会给每个页面打一些tag。这些tag将作为搜索结果排序的重要依据。比如用户搜索“北京旅游”,搜索引擎会检索收录页面中被打了“北京旅游”tag的页面,并根据一系列规则进行排序。所以,如何提升这些tag在搜索引擎里面的权重是至关重要的。

  1. TDK优化
TDK是个缩写,seo页面中的页面描述与关键词设置。

其中"T"代表页头中的title元素,这里可能还要利用到分词技术,当标题(Title)写好后,我们就尽可> 能不要再去修改了,尽量简洁,没意义的词尽量不要加入到标题中,避免干扰到搜索引擎识别网站主题。

其中"D"代表页头中的description元素,要知道描述是对网页的一个概述,也是对title的补充,因为title中只能书写有限的字数,所以在description中就要稍微详细的补充起来,一般用一句两句话概括文章的内容。

其中"K"代表页头中的keywords元素,提取页面中的主要关键词,数量控制在三到六个内。想方设法让主关键字都出现。

  以上内容摘自百度百科,这里需要补充几点。

  TDK是搜索引擎判断页面主题内容的关键,所以要在title里面言简意赅的体现出页面的主要内容,如果主体比较多,可以用一些符号把不同的主题词隔开,但是关键词不要太多,最多不要超过五个。

  keywords里面把每个关键词用英文逗号隔开,三到五个最佳。尽量覆盖每个关键词。

  description就是用自然语言描述页面的主要内容,这里注意一点就是把每个关键词至少覆盖一遍才能达到最佳效果。

  1. 提升页面关键词密度

  首先说个概念,叫关键词密度。简单理解就是关键词在所有文字内容中出现的比例。提升关键词的密度,有利于提升搜索引擎针对对应关键词的搜索排名。但并不是我们整个页面密密麻麻堆砌关键次就好,我们来分析一个案例。

  我们在百度搜索“北京旅游”,排在第一的是百度旅游,这个就不解释了。排第二位是携程,我们就分析一下为啥携程会排名这么靠前。

  通过查看百度快照,可以一目了然的看到页面上究竟哪些地方命中了这些词。

  页面头部

  页面底部

  页面很清晰表明了关键词出现的地方,我们发现这个页面除了正文部分外,还设置了许多的模块,这些模块看似只是一些简单的链接,实际上他们更重要的使命就是服务SEO,提升关键词的密度。同时,这些链接都是指向网站内部的链接,通过这样的方式,还可以在不同的页面之间相互投权重。可以说小链接,大学问!

  你以为到此结束了?并没有。请仔细观察页面上这些模块的内容设置。分别覆盖了地区、景点、攻略、导航、住宿、交通等等,可以说是涵盖了你要旅游所需要的任何需求。这样一来,不管你搜哪些有关于“北京旅游”的关键词,比如“北京旅游住宿”,“北京特色美食”等都会命中这个页面的词,这使得这个页面的关键词数量得到提升,更容易得到曝光。

  1. 细枝末节但不可忽视的优化

  页面上经常会有各种图片,对于搜索引擎来说,它是不识别图片上的内容的。你可能知道代码中img标签的alt属性是为了图片加载失败的时候,给用户看的。这个属性表明了这张图的内容。其实搜索引擎在分析页面的时候,也会根据这个词去判断图片的内容,所以要给页面上有意义的图片都加上alt属性,写清楚图片索要反映的内容。

  页面上的出站链接(也就是指向别的网站的A标签),我们要给它加上nofollow标签,避免它向别的网站输出权重。百度蜘蛛会忽略加了nofollow 的链接。你也可以在网页的meta标签里这么写<meta name="robots" content="nofollow" />,这样一来,百度蜘蛛将不追踪页面上的所有链接,但不建议这么做,除非这个页面的所有链接都指向了别的域名。

  ......

其他优化点

友情链接

  我们经常会在页面底部看到友情链接。友情链接是作为网站之间相互交换流量,互惠互利的合作形式。事实上,友情链接对网站权重提升有着至关重要的作用。友链不仅可以引导用户浏览,而且搜索引擎也会顺着链接形成循环爬取,可以有效提升网站流量和快照的更新速度。

关键词筛选

  借助站长工具爱站网或者各种站长后台我们可以分析出ip来路,以及关键词的搜索热度和相关词,我们再把这些词以一定的密度添加到页面中,以此来提升命中率。这里主要是运营同学的工作,我不专业,也就不展开说了,更多功能大家自行摸索。

利用好分析工具

  我们要在自己的站点安装百度统计代码,这样就可以分析出站点内用户的关注度和浏览流程,以此来不断优化站点结构,提升用户的留存率。同时也可以做用户画像,分析用户数据等等。

结语

  想要做好SEO并不是一件简单的事,需要持之以恒,面面俱到。对网站持续关注,并保持更新。从长远打算,切不可投机取巧,只图一时的效果做违背搜索引擎的操作,也就是常说的黑帽SEO,否则被百度K掉就得不偿失了。

  以上观点只是我最近学习的一些总结,并不权威,希望给不了解这块的研发同学简单扫个盲,如有错误,还请各位指正与补充!

查看原文

alicorn 收藏了文章 · 2018-05-27

webpack优化

webpack优化

查看所有文档页面:全栈开发,获取更多信息。

原文链接:webpack优化,原文广告模态框遮挡,阅读体验不好,所以整理成本文,方便查找。

优化开发体验

  1. 优化构建速度。在项目庞大时构建耗时可能会变的很长,每次等待构建的耗时加起来也会是个大数目。

    • 缩小文件搜索范围
    • 使用 DllPlugin
    • 使用 HappyPack
    • 使用 ParallelUglifyPlugin
  2. 优化使用体验。通过自动化手段完成一些重复的工作,让我们专注于解决问题本身。

    • 使用自动刷新
    • 开启模块热替换

优化输出质量

优化输出质量的目的是为了给用户呈现体验更好的网页,例如减少首屏加载时间、提升性能流畅度等。 这至关重要,因为在互联网行业竞争日益激烈的今天,这可能关系到你的产品的生死。

优化输出质量本质是优化构建输出的要发布到线上的代码,分为以下几点:

  1. 减少用户能感知到的加载时间,也就是首屏加载时间。

    • 区分环境
    • 压缩代码
    • CDN 加速
    • 使用 Tree Shaking
    • 提取公共代码
    • 按需加载
  2. 提升流畅度,也就是提升代码性能。

    • 使用 Prepack
    • 开启 Scope Hoisting

缩小文件搜索范围

Webpack 启动后会从配置的 Entry 出发,解析出文件中的导入语句,再递归的解析。 在遇到导入语句时 Webpack 会做两件事情:

  1. 根据导入语句去寻找对应的要导入的文件。例如 require('react') 导入语句对应的文件是 ./node_modules/react/react.js,require('./util') 对应的文件是 ./util.js
  2. 根据找到的要导入文件的后缀,使用配置中的 Loader 去处理文件。例如使用 ES6 开发的 JavaScript 文件需要使用 babel-loader 去处理。

优化 loader 配置

由于 Loader 对文件的转换操作很耗时,需要让尽可能少的文件被 Loader 处理。

在 Module 中介绍过在使用 Loader 时可以通过 testincludeexclude 三个配置项来命中 Loader 要应用规则的文件。 为了尽可能少的让文件被 Loader 处理,可以通过 include 去命中只有哪些文件需要被处理。

以采用 ES6 的项目为例,在配置 babel-loader 时,可以这样:

module.exports = {
  module: {
    rules: [
      {
        // 如果项目源码中只有 js 文件就不要写成 /\.jsx?$/,提升正则表达式性能
        test: /\.js$/,
        // babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
        use: ['babel-loader?cacheDirectory'],
        // 只对项目根目录下的 src 目录中的文件采用 babel-loader
        include: path.resolve(__dirname, 'src'),
      },
    ]
  },
};


你可以适当的调整项目的目录结构,以方便在配置 Loader 时通过 include 去缩小命中范围。

优化 resolve.modules 配置

在 Resolve 中介绍过 resolve.modules 用于配置 Webpack 去哪些目录下寻找第三方模块。

resolve.modules 的默认值是 ['node_modules'],含义是先去当前目录下的 ./node_modules 目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules 中找,再没有就去 ../../node_modules 中找,以此类推,这和 Node.js 的模块寻找机制很相似。

当安装的第三方模块都放在项目根目录下的 ./node_modules 目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:


module.exports = {
  resolve: {
    // 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
    // 其中 __dirname 表示当前工作目录,也就是项目根目录
    modules: [path.resolve(__dirname, 'node_modules')]
  },
};

优化 resolve.mainFields 配置

在 Resolve 中介绍过 resolve.mainFields 用于配置第三方模块使用哪个入口文件。

安装的第三方模块中都会有一个 package.json 文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields 用于配置采用哪个字段作为入口文件的描述。

可以存在多个字段描述入口文件的原因是因为有些模块可以同时用在多个环境中,准对不同的运行环境需要使用不同的代码。 以 isomorphic-fetch 为例,它是 fetch API 的一个实现,但可同时用于浏览器和 Node.js 环境。 它的 package.json 中就有2个入口文件描述字段:

{
  "browser": "fetch-npm-browserify.js",
  "main": "fetch-npm-node.js"
}   
isomorphic-fetch 在不同的运行环境下使用不同的代码是因为 fetch API 的实现机制不一样,在浏览器中通过原生的 fetch 或者 XMLHttpRequest 实现,在 Node.js 中通过 http 模块实现。

resolve.mainFields 的默认值和当前的 target 配置有关系,对应关系如下:

  • targetweb 或者 webworker 时,值是 ["browser", "module", "main"]
  • target 为其它情况时,值是 ["module", "main"]

target 等于 web 为例,Webpack 会先采用第三方模块中的 browser 字段去寻找模块的入口文件,如果不存在就采用 module 字段,以此类推。

为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用 main 字段去描述入口文件的位置,可以这样配置 Webpack:

module.exports = {
  resolve: {
    // 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
    mainFields: ['main'],
  },
};   
使用本方法优化时,你需要考虑到所有运行时依赖的第三方模块的入口文件描述字段,就算有一个模块搞错了都可能会造成构建出的代码无法正常运行。

优化 resolve.alias 配置

resolve.alias 配置项通过别名来把原导入路径映射成一个新的导入路径。

在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,安装到 node_modules 目录下的 React 库的目录结构如下:

├── dist
│   ├── react.js
│   └── react.min.js
├── lib
│   ... 还有几十个文件被忽略
│   ├── LinkedStateMixin.js
│   ├── createClass.js
│   └── React.js
├── package.json
└── react.js

可以看到发布出去的 React 库中包含两套代码:

  • 一套是采用 CommonJS 规范的模块化代码,这些文件都放在 lib 目录下,以 package.json 中指定的入口文件 react.js 为模块的入口。
  • 一套是把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中 dist/react.js 是用于开发环境,里面包含检查和警告的代码。dist/react.min.js 是用于线上环境,被最小化了。

默认情况下 Webpack 会从入口文件 ./node_modules/react/react.js 开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置 resolve.alias 可以让 Webpack 在处理 React 库时,直接使用单独完整的 react.min.js 文件,从而跳过耗时的递归解析操作。

相关 Webpack 配置如下:

module.exports = {
  resolve: {
    // 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.min.js 文件,
    // 减少耗时的递归解析操作
    alias: {
      'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
    }
  },
};

除了 React 库外,大多数库发布到 Npm 仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置 alias

但是对于有些库使用本优化方法后会影响到后面要讲的使用 Tree-Shaking 去除无效代码的优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。 一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。 但是对于一些工具类的库,例如 lodash,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。

优化 resolve.extensions 配置

在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试询问文件是否存在。resolve.extensions 用于配置在尝试过程中用到的后缀列表,默认是:

extensions: ['.js', '.json']

也就是说当遇到 require('./data') 这样的导入语句时,Webpack 会先去寻找 ./data.js 文件,如果该文件不存在就去寻找 ./data.json 文件,如果还是找不到就报错。

如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。 在配置 resolve.extensions 时你需要遵守以下几点,以做到尽可能的优化构建性能:

  • 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中。
  • 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
  • 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把 require('./data') 写成 require('./data.json')

相关 Webpack 配置如下:

module.exports = {
  resolve: {
    // 尽可能的减少后缀尝试的可能性
    extensions: ['js'],
  },
};

优化 module.noParse 配置

module.noParse 配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。

在上面的 优化 resolve.alias 配置 中讲到单独完整的 react.min.js 文件就没有采用模块化,让我们来通过配置 module.noParse 忽略对 react.min.js 文件的递归解析处理, 相关 Webpack 配置如下:

const path = require('path');

module.exports = {
  module: {
    // 独完整的 `react.min.js` 文件就没有采用模块化,忽略对 `react.min.js` 文件的递归解析处理
    noParse: [/react\.min\.js$/],
  },
};
注意被忽略掉的文件里不应该包含 importrequiredefine 等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。

以上就是所有和缩小文件搜索范围相关的构建性能优化了,在根据自己项目的需要去按照以上方法改造后,你的构建速度一定会有所提升。

使用 DllPlugin

要给 Web 项目构建接入动态链接库的思想,需要完成以下事情:

  • 把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块。
  • 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取。
  • 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取。

为什么给 Web 项目构建接入动态链接库的思想后,会大大提升构建速度呢? 原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。 由于动态链接库中大多数包含的是常用的第三方模块,例如 reactreact-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。

接入 Webpack

Webpack 已经内置了对动态链接库的支持,需要通过2个内置的插件接入,它们分别是:

  • DllPlugin 插件:用于打包出一个个单独的动态链接库文件。
  • DllReferencePlugin 插件:用于在主要配置文件中去引入 DllPlugin 插件打包好的动态链接库文件。

下面以基本的 React 项目为例,为其接入 DllPlugin,在开始前先来看下最终构建出的目录结构:

├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json

其中包含两个动态链接库文件,分别是:

  • polyfill.dll.js 里面包含项目所有依赖的 polyfill,例如 Promise、fetch 等 API。
  • react.dll.js 里面包含 React 的基础运行环境,也就是 reactreact-dom 模块。

react.dll.js 文件为例,其文件内容大致如下:

var _dll_react = (function(modules) {
  // ... 此处省略 webpackBootstrap 函数代码
}([
  function(module, exports, __webpack_require__) {
    // 模块 ID 为 0 的模块对应的代码
  },
  function(module, exports, __webpack_require__) {
    // 模块 ID 为 1 的模块对应的代码
  },
  // ... 此处省略剩下的模块对应的代码 
]));

可见一个动态链接库文件中包含了大量模块的代码,这些模块存放在一个数组里,用数组的索引号作为 ID。 并且还通过 _dll_react 变量把自己暴露在了全局中,也就是可以通过 window._dll_react 可以访问到它里面包含的模块。

其中 polyfill.manifest.jsonreact.manifest.json 文件也是由 DllPlugin 生成出,用于描述动态链接库文件中包含哪些模块, 以 react.manifest.json 文件为例,其文件内容大致如下:

<p data-height="565" data-theme-id="0" data-slug-hash="GdVvmZ" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="react.manifest.json" class="codepen">See the Pen react.manifest.json by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

可见 manifest.json 文件清楚地描述了与其对应的 dll.js 文件中包含了哪些模块,以及每个模块的路径和 ID。

main.js 文件是编译出来的执行入口文件,当遇到其依赖的模块在 dll.js 文件中时,会直接通过 dll.js 文件暴露出的全局变量去获取打包在 dll.js 文件的模块。 所以在 index.html 文件中需要把依赖的两个 dll.js 文件给加载进去,index.html 内容如下:


<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--导入依赖的动态链接库文件-->
<script data-original="./dist/polyfill.dll.js"></script>
<script data-original="./dist/react.dll.js"></script>
<!--导入执行入口文件-->
<script data-original="./dist/main.js"></script>
</body>
</html>

以上就是所有接入 DllPlugin 后最终编译出来的代码,接下来教你如何实现。

构建出动态链接库文件

构建输出的以下这四个文件:

├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json

和以下这一个文件:

├── main.js

是由两份不同的构建分别输出的。

动态链接库文件相关的文件需要由一份独立的构建输出,用于给主构建使用。新建一个 Webpack 配置文件 webpack_dll.config.js 专门用于构建它们,文件内容如下:

<p data-height="665" data-theme-id="0" data-slug-hash="MGNvrB" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="webpack_dll.config.js" class="codepen">See the Pen webpack_dll.config.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

使用动态链接库文件

构建出的动态链接库文件用于给其它地方使用,在这里也就是给执行入口使用。

用于输出 main.js 的主 Webpack 配置文件内容如下:

<p data-height="720" data-theme-id="0" data-slug-hash="GdVvxj" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="main.js" class="codepen">See the Pen main.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

注意:在 webpack_dll.config.js 文件中,DllPlugin 中的 name 参数必须和 output.library 中保持一致。 原因在于 DllPlugin 中的 name 参数会影响输出的 manifest.json 文件中 name 字段的值, 而在 webpack.config.js 文件中 DllReferencePlugin 会去 manifest.json 文件读取 name 字段的值, 把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名。

执行构建

在修改好以上两个 Webpack 配置文件后,需要重新执行构建。 重新执行构建时要注意的是需要先把动态链接库相关的文件编译出来,因为主 Webpack 配置文件中定义的 DllReferencePlugin 依赖这些文件。

执行构建时流程如下:

  1. 如果动态链接库相关的文件还没有编译出来,就需要先把它们编译出来。方法是执行 webpack --config webpack_dll.config.js 命令。
  2. 在确保动态链接库存在时,才能正常的编译出入口执行文件。方法是执行 webpack 命令。这时你会发现构建速度有了非常大的提升。

使用 HappyPack

由于有大量文件需要解析和处理,构建是文件读写和计算密集型的操作,特别是当文件数量变多后,Webpack 构建慢的问题会显得严重。 运行在 Node.js 之上的 Webpack 是单线程模型的,也就是说 Webpack 需要处理的任务需要一件件挨着做,不能多个事情一起做。

文件读写和计算操作是无法避免的,那能不能让 Webpack 同一时刻处理多个任务,发挥多核 CPU 电脑的威力,以提升构建速度呢?

HappyPack 就能让 Webpack 做到这点,它把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程。

由于 JavaScript 是单线程模型,要想发挥多核 CPU 的能力,只能通过多进程去实现,而无法通过多线程实现。

分解任务和管理线程的事情 HappyPack 都会帮你做好,你所需要做的只是接入 HappyPack。 接入 HappyPack 的相关代码如下:

<p data-height="665" data-theme-id="0" data-slug-hash="RyXLEy" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="HappyPack " class="codepen">See the Pen HappyPack by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上代码有两点重要的修改:

  • 在 Loader 配置中,所有文件的处理都交给了 happypack/loader 去处理,使用紧跟其后的 querystring ?id=babel 去告诉 happypack/loader 去选择哪个 HappyPack 实例去处理文件。
  • 在 Plugin 配置中,新增了两个 HappyPack 实例分别用于告诉 happypack/loader 去如何处理 .js .css 文件。选项中的 id 属性的值和上面 querystring 中的 ?id=babel 相对应,选项中的 loaders 属性和 Loader 配置中一样。

在实例化 HappyPack 插件的时候,除了可以传入 idloaders 两个参数外,HappyPack 还支持如下参数:

  • threads 代表开启几个子进程去处理这一类型的文件,默认是3个,类型必须是整数。
  • verbose 是否允许 HappyPack 输出日志,默认是 true
  • threadPool 代表共享进程池,即多个 HappyPack 实例都使用同一个共享进程池中的子进程去处理任务,以防止资源占用过多,相关代码如下:

<p data-height="465" data-theme-id="0" data-slug-hash="MGNERw" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="threadPool " class="codepen">See the Pen threadPool by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

接入 HappyPack 后,你需要给项目安装新的依赖:

npm i -D happypack

HappyPack 原理

在整个 Webpack 构建流程中,最耗时的流程可能就是 Loader 对文件的转换操作了,因为要转换的文件数据巨多,而且这些转换操作都只能一个个挨着处理。 HappyPack 的核心原理就是把这部分任务分解到多个进程去并行处理,从而减少了总的构建时间。

从前面的使用中可以看出所有需要通过 Loader 处理的文件都先交给了 happypack/loader 去处理,收集到了这些文件的处理权后 HappyPack 就好统一分配了。

每通过 new HappyPack() 实例化一个 HappyPack 其实就是告诉 HappyPack 核心调度器如何通过一系列 Loader 去转换一类文件,并且可以指定如何给这类转换操作分配子进程。

核心调度器的逻辑代码在主进程中,也就是运行着 Webpack 的进程中,核心调度器会把一个个任务分配给当前空闲的子进程,子进程处理完毕后把结果发送给核心调度器,它们之间的数据交换是通过进程间通信 API 实现的。

核心调度器收到来自子进程处理完毕的结果后会通知 Webpack 该文件处理完毕。

使用 ParallelUglifyPlugin

在使用 Webpack 构建出用于发布到线上的代码时,都会有压缩代码这一流程。 最常见的 JavaScript 代码压缩工具是 UglifyJS,并且 Webpack 也内置了它。

用过 UglifyJS 的你一定会发现在构建用于开发环境的代码时很快就能完成,但在构建用于线上的代码时构建一直卡在一个时间点迟迟没有反应,其实卡住的这个时候就是在进行代码压缩。

由于压缩 JavaScript 代码需要先把代码解析成用 Object 抽象表示的 AST 语法树,再去应用各种规则分析和处理 AST,导致这个过程计算量巨大,耗时非常多。

为什么不把在使用 HappyPack中介绍过的多进程并行处理的思想也引入到代码压缩中呢?

ParallelUglifyPlugin 就做了这个事情。 当 Webpack 有多个 JavaScript 文件需要输出和压缩时,原本会使用 UglifyJS 去一个个挨着压缩再输出, 但是 ParallelUglifyPlugin 则会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过 UglifyJS 去压缩代码,但是变成了并行执行。 所以 ParallelUglifyPlugin 能更快的完成对多个文件的压缩工作。

使用 ParallelUglifyPlugin 也非常简单,把原来 Webpack 配置文件中内置的 UglifyJsPlugin 去掉后,再替换成 ParallelUglifyPlugin,相关代码如下:

<p data-height="585" data-theme-id="0" data-slug-hash="BxXwgM" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="ParallelUglifyPlugin" class="codepen">See the Pen ParallelUglifyPlugin by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

在通过 new ParallelUglifyPlugin() 实例化时,支持以下参数:

  • test:使用正则去匹配哪些文件需要被 ParallelUglifyPlugin 压缩,默认是 /.js$/,也就是默认压缩所有的 .js 文件。
  • include:使用正则去命中需要被 ParallelUglifyPlugin 压缩的文件。默认为 []
  • exclude:使用正则去命中不需要被 ParallelUglifyPlugin 压缩的文件。默认为 []
  • cacheDir:缓存压缩后的结果,下次遇到一样的输入时直接从缓存中获取压缩后的结果并返回。cacheDir 用于配置缓存存放的目录路径。默认不会缓存,想开启缓存请设置一个目录路径。
  • workerCount:开启几个子进程去并发的执行压缩。默认是当前运行电脑的 CPU 核数减去1。
  • sourceMap:是否输出 Source Map,这会导致压缩过程变慢。
  • uglifyJS:用于压缩 ES5 代码时的配置,Object 类型,直接透传给 UglifyJS 的参数。
  • uglifyES:用于压缩 ES6 代码时的配置,Object 类型,直接透传给 UglifyES 的参数。

其中的 testincludeexclude 与配置 Loader 时的思想和用法一样。

UglifyES 是 UglifyJS 的变种,专门用于压缩 ES6 代码,它们两都出自于同一个项目,并且它们两不能同时使用。

UglifyES 一般用于给比较新的 JavaScript 运行环境压缩代码,例如用于 ReactNative 的代码运行在兼容性较好的 JavaScriptCore 引擎中,为了得到更好的性能和尺寸,采用 UglifyES 压缩效果会更好。

ParallelUglifyPlugin 同时内置了 UglifyJS 和 UglifyES,也就是说 ParallelUglifyPlugin 支持并行压缩 ES6 代码。

接入 ParallelUglifyPlugin 后,项目需要安装新的依赖:

npm i -D webpack-parallel-uglify-plugin

安装成功后,重新执行构建你会发现速度变快了许多。如果设置 cacheDir 开启了缓存,在之后的构建中会变的更快。

使用自动刷新

在开发阶段,修改源码是不可避免的操作。 对于开发网页来说,要想看到修改后的效果,需要刷新浏览器让其重新运行最新的代码才行。 虽然这相比于开发原生 iOS 和 Android 应用来说要方便很多,因为那需要重新编译这个项目再运行,但我们可以把这个体验优化的更好。 借助自动化的手段,可以把这些重复的操作交给代码去帮我们完成,在监听到本地源码文件发生变化时,自动重新构建出可运行的代码后再控制浏览器刷新。

Webpack 把这些功能都内置了,并且还提供多种方案可选。

文件监听

文件监听是在发现源码文件发生变化时,自动重新构建出新的输出文件。

Webpack 官方提供了两大模块,一个是核心的 webpack,一个是在使用 DevServer 中提到的 webpack-dev-server 扩展模块。 而文件监听功能是 webpack 模块提供的。

其它配置项中曾介绍过 Webpack 支持文件监听相关的配置项如下:

module.export = {
  // 只有在开启监听模式时,watchOptions 才有意义
  // 默认为 false,也就是不开启
  watch: true,
  // 监听模式运行时的参数
  // 在开启监听模式时,才有意义
  watchOptions: {
    // 不监听的文件或文件夹,支持正则匹配
    // 默认为空
    ignored: /node_modules/,
    // 监听到变化发生后会等300ms再去执行动作,防止文件更新太快导致重新编译频率太高
    // 默认为 300ms
    aggregateTimeout: 300,
    // 判断文件是否发生变化是通过不停的去询问系统指定文件有没有变化实现的
    // 默认每秒问 1000 次
    poll: 1000
  }
}

要让 Webpack 开启监听模式,有两种方式:

  • 在配置文件 webpack.config.js 中设置 watch: true
  • 在执行启动 Webpack 命令时,带上 --watch 参数,完整命令是 webpack --watch

文件监听工作原理

在 Webpack 中监听一个文件发生变化的原理是定时的去获取这个文件的最后编辑时间,每次都存下最新的最后编辑时间,如果发现当前获取的和最后一次保存的最后编辑时间不一致,就认为该文件发生了变化。 配置项中的 watchOptions.poll 就是用于控制定时检查的周期,具体含义是每秒检查多少次。

当发现某个文件发生了变化时,并不会立刻告诉监听者,而是先缓存起来,收集一段时间的变化后,再一次性告诉监听者。 配置项中的 watchOptions.aggregateTimeout 就是用于配置这个等待时间。 这样做的目的是因为我们在编辑代码的过程中可能会高频的输入文字导致文件变化的事件高频的发生,如果每次都重新执行构建就会让构建卡死。

对于多个文件来说,原理相似,只不过会对列表中的每一个文件都定时的执行检查。 但是这个需要监听的文件列表是怎么确定的呢? 默认情况下 Webpack 会从配置的 Entry 文件出发,递归解析出 Entry 文件所依赖的文件,把这些依赖的文件都加入到监听列表中去。 可见 Webpack 这一点还是做的很智能的,不是粗暴的直接监听项目目录下的所有文件。

由于保存文件的路径和最后编辑时间需要占用内存,定时检查周期检查需要占用 CPU 以及文件 I/O,所以最好减少需要监听的文件数量和降低检查频率。

优化文件监听性能

在明白文件监听工作原理后,就好分析如何优化文件监听性能了。

开启监听模式时,默认情况下会监听配置的 Entry 文件和所有其递归依赖的文件。 在这些文件中会有很多存在于 node_modules 下,因为如今的 Web 项目会依赖大量的第三方模块。 在大多数情况下我们都不可能去编辑 node_modules 下的文件,而是编辑自己建立的源码文件。 所以一个很大的优化点就是忽略掉 node_modules 下的文件,不监听它们。相关配置如下:

module.export = {
  watchOptions: {
    // 不监听的 node_modules 目录下的文件
    ignored: /node_modules/,
  }
}

采用这种方法优化后,你的 Webpack 消耗的内存和 CPU 将会大大降低。

有时你可能会觉得 node_modules 目录下的第三方模块有 bug,想修改第三方模块的文件,然后在自己的项目中试试。 在这种情况下如果使用了以上优化方法,我们需要重启构建以看到最新效果。 但这种情况毕竟是非常少见的。

除了忽略掉部分文件的优化外,还有如下两种方法:

  • watchOptions.aggregateTimeout 值越大性能越好,因为这能降低重新构建的频率。
  • watchOptions.poll 值越小越好,因为这能降低检查的频率。

但两种优化方法的后果是会让你感觉到监听模式的反应和灵敏度降低了。

自动刷新浏览器

监听到文件更新后的下一步是去刷新浏览器,webpack 模块负责监听文件,webpack-dev-server 模块则负责刷新浏览器。 在使用 webpack-dev-server 模块去启动 webpack 模块时,webpack 模块的监听模式默认会被开启。 webpack 模块会在文件发生变化时告诉 webpack-dev-server 模块。

自动刷新的原理

控制浏览器刷新有三种方法:

  1. 借助浏览器扩展去通过浏览器提供的接口刷新,WebStorm IDE 的 LiveEdit 功能就是这样实现的。
  2. 往要开发的网页中注入代理客户端代码,通过代理客户端去刷新整个页面。
  3. 把要开发的网页装进一个 iframe 中,通过刷新 iframe 去看到最新效果。

DevServer 支持第2、3种方法,第2种是 DevServer 默认采用的刷新方法。

优化自动刷新的性能

在DevServer中曾介绍过 devServer.inline 配置项,它就是用来控制是否往 Chunk 中注入代理客户端的,默认会注入。 事实上,在开启 inline 时,DevServer 会为每个输出的 Chunk 中注入代理客户端的代码,当你的项目需要输出的 Chunk 有很多个时,这会导致你的构建缓慢。 其实要完成自动刷新,一个页面只需要一个代理客户端就行了,DevServer 之所以粗暴的为每个 Chunk 都注入,是因为它不知道某个网页依赖哪几个 Chunk,索性就全部都注入一个代理客户端。 网页只要依赖了其中任何一个 Chunk,代理客户端就被注入到网页中去。

这里优化的思路是关闭还不够优雅的 inline 模式,只注入一个代理客户端。 为了关闭 inline 模式,在启动 DevServer 时,可通过执行命令 webpack-dev-server --inline false(也可以在配置文件中设置)。

要开发的网页被放进了一个 iframe 中,编辑源码后,iframe 会被自动刷新。 同时你会发现构建时间从 1566ms 减少到了 1130ms,说明优化生效了。构建性能提升的效果在要输出的 Chunk 数量越多时会显得越突出。

在你关闭了 inline 后,DevServer 会自动地提示你通过新网址 http://localhost:8080/webpack-dev-server/ 去访问,这点是做的很人心化的。

如果你不想通过 iframe 的方式去访问,但同时又想让网页保持自动刷新功能,你需要手动往网页中注入代理客户端脚本,往 index.html 中插入以下标签:

<!--注入 DevServer 提供的代理客户端脚本,这个服务是 DevServer 内置的-->
<script data-original="http://localhost:8080/webpack-dev-server.js"></script>

给网页注入以上脚本后,独立打开的网页就能自动刷新了。但是要注意在发布到线上时记得删除掉这段用于开发环境的代码。

开启模块热替换

要做到实时预览,除了在使用自动刷新中介绍的刷新整个网页外,DevServer 还支持一种叫做模块热替换(Hot Module Replacement)的技术可在不刷新整个网页的情况下做到超灵敏的实时预览。 原理是当一个源码发生变化时,只重新编译发生变化的模块,再用新输出的模块替换掉浏览器中对应的老模块。

模块热替换技术的优势有:

  • 实时预览反应更快,等待时间更短。
  • 不刷新浏览器能保留当前网页的运行状态,例如在使用 Redux 来管理数据的应用中搭配模块热替换能做到代码更新时 Redux 中的数据还保持不变。

总的来说模块热替换技术很大程度上的提高了开发效率和体验。

模块热替换的原理

模块热替换的原理和自动刷新原理类似,都需要往要开发的网页中注入一个代理客户端用于连接 DevServer 和网页, 不同在于模块热替换独特的模块替换机制。

DevServer 默认不会开启模块热替换模式,要开启该模式,只需在启动时带上参数 --hot,完整命令是 webpack-dev-server --hot

除了通过在启动时带上 --hot 参数,还可以通过接入 Plugin 实现,相关代码如下:

const HotModuleReplacementPlugin = require('webpack/lib/HotModuleReplacementPlugin');

module.exports = {
  entry:{
    // 为每个入口都注入代理客户端
    main:['webpack-dev-server/client?http://localhost:8080/', 'webpack/hot/dev-server','./src/main.js'],
  },
  plugins: [
    // 该插件的作用就是实现模块热替换,实际上当启动时带上 `--hot` 参数,会注入该插件,生成 .hot-update.json 文件。
    new HotModuleReplacementPlugin(),
  ],
  devServer:{
    // 告诉 DevServer 要开启模块热替换模式
    hot: true,      
  }  
};

在启动 Webpack 时带上参数 --hot 其实就是自动为你完成以上配置。

相比于自动刷新的代理客户端,多出了后三个用于模块热替换的文件,也就是说代理客户端更大了。

可见补丁中包含了 main.css 文件新编译出来 CSS 代码,网页中的样式也立刻变成了源码中描述的那样。

但当你修改 main.js 文件时,会发现模块热替换没有生效,而是整个页面被刷新了,为什么修改 main.js 文件时会这样呢?

Webpack 为了让使用者在使用了模块热替换功能时能灵活地控制老模块被替换时的逻辑,可以在源码中定义一些代码去做相应的处理。

把的 main.js 文件改为如下:

<p data-height="365" data-theme-id="0" data-slug-hash="QreOEw" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="main.js" class="codepen">See the Pen main.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

其中的 module.hot 是当开启模块热替换后注入到全局的 API,用于控制模块热替换的逻辑。

现在修改 AppComponent.js 文件,把 Hello,Webpack 改成 Hello,World,你会发现模块热替换生效了。 但是当你编辑 main.js 时,你会发现整个网页被刷新了。为什么修改这两个文件会有不一样的表现呢?

当子模块发生更新时,更新事件会一层层往上传递,也就是从 AppComponent.js 文件传递到 main.js 文件, 直到有某层的文件接受了当前变化的模块,也就是 main.js 文件中定义的 module.hot.accept(['./AppComponent'], callback), 这时就会调用 callback 函数去执行自定义逻辑。如果事件一直往上抛到最外层都没有文件接受它,就会直接刷新网页。

那为什么没有地方接受过 .css 文件,但是修改所有的 .css 文件都会触发模块热替换呢? 原因在于 style-loader 会注入用于接受 CSS 的代码。

请不要把模块热替换技术用于线上环境,它是专门为提升开发效率生的。

优化模块热替换

其中的 Updated modules: 68 是指 ID 为68的模块被替换了,这对开发者来说很不友好,因为开发者不知道 ID 和模块之间的对应关系,最好是把替换了的模块的名称输出出来。 Webpack 内置的 NamedModulesPlugin 插件可以解决该问题,修改 Webpack 配置文件接入该插件:

const NamedModulesPlugin = require('webpack/lib/NamedModulesPlugin');

module.exports = {
  plugins: [
    // 显示出被替换模块的名称
    new NamedModulesPlugin(),
  ],
};

除此之外,模块热替换还面临着和自动刷新一样的性能问题,因为它们都需要监听文件变化和注入客户端。 要优化模块热替换的构建性能,思路和在使用自动刷新中提到的很类似:监听更少的文件,忽略掉 node_modules 目录下的文件。 但是其中提到的关闭默认的 inline 模式手动注入代理客户端的优化方法不能用于在使用模块热替换的情况下, 原因在于模块热替换的运行依赖在每个 Chunk 中都包含代理客户端的代码。

区分环境

为什么需要区分环境

在开发网页的时候,一般都会有多套运行环境,例如:

  1. 在开发过程中方便开发调试的环境。
  2. 发布到线上给用户使用的运行环境。

这两套不同的环境虽然都是由同一套源代码编译而来,但是代码内容却不一样,差异包括:

  • 线上代码被通过压缩代码 中提到的方法压缩过。
  • 开发用的代码包含一些用于提示开发者的提示日志,这些日志普通用户不可能去看它。
  • 开发用的代码所连接的后端数据接口地址也可能和线上环境不同,因为要避免开发过程中造成对线上数据的影响。

为了尽可能的复用代码,在构建的过程中需要根据目标代码要运行的环境而输出不同的代码,我们需要一套机制在源码中去区分环境。 幸运的是 Webpack 已经为我们实现了这点。

如何区分环境

具体区分方法很简单,在源码中通过如下方式:

if (process.env.NODE_ENV === 'production') {
  console.log('你正在线上环境');
} else {
  console.log('你正在使用开发环境');
}

其大概原理是借助于环境变量的值去判断执行哪个分支。

当你的代码中出现了使用 process 模块的语句时,Webpack 就自动打包进 process 模块的代码以支持非 Node.js 的运行环境。 当你的代码中没有使用 process 时就不会打包进 process 模块的代码。这个注入的 process 模块作用是为了模拟 Node.js 中的 process,以支持上面使用的 process.env.NODE_ENV === 'production' 语句。

在构建线上环境代码时,需要给当前运行环境设置环境变量 NODE_ENV = 'production',Webpack 相关配置如下:

const DefinePlugin = require('webpack/lib/DefinePlugin');

module.exports = {
  plugins: [
    new DefinePlugin({
      // 定义 NODE_ENV 环境变量为 production
      'process.env': {
        NODE_ENV: JSON.stringify('production')
      }
    }),
  ],
};


注意在定义环境变量的值时用 JSON.stringify 包裹字符串的原因是环境变量的值需要是一个由双引号包裹的字符串,而 JSON.stringify('production')的值正好等于'"production"'

执行构建后,你会在输出的文件中发现如下代码:

if (true) {
  console.log('你正在使用线上环境');
} else {
  console.log('你正在使用开发环境');
}

定义的环境变量的值被代入到了源码中,process.env.NODE_ENV === 'production' 被直接替换成了 true。 并且由于此时访问 process 的语句被替换了而没有了,Webpack 也不会打包进 process 模块了。

DefinePlugin 定义的环境变量只对 Webpack 需要处理的代码有效,而不会影响 Node.js 运行时的环境变量的值。

通过 Shell 脚本的方式去定义的环境变量,例如 NODE_ENV=production webpack,Webpack 是不认识的,对 Webpack 需要处理的代码中的环境区分语句是没有作用的。

也就是说只需要通过 DefinePlugin 定义环境变量就能使上面介绍的环境区分语句正常工作,没必要又通过 Shell 脚本的方式去定义一遍。

如果你想让 Webpack 使用通过 Shell 脚本的方式去定义的环境变量,你可以使用 EnvironmentPlugin,代码如下:

new webpack.EnvironmentPlugin(['NODE_ENV'])

以上这句代码实际上等价于:

new webpack.DefinePlugin({
  'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
})

结合 UglifyJS

其实以上输出的代码还可以进一步优化,因为 if(true) 语句永远只会执行前一个分支中的代码,也就是说最佳的输出其实应该直接是:

console.log('你正在线上环境');

Webpack 没有实现去除死代码功能,但是 UglifyJS 可以做这个事情,如何使用请阅读 压缩代码 中的压缩 JavaScript。

第三方库中的环境区分

除了在自己写的源码中可以有环境区分的代码外,很多第三方库也做了环境区分的优化。 以 React 为例,它做了两套环境区分,分别是:

  1. 开发环境:包含类型检查、HTML 元素检查等等针对开发者的警告日志代码。
  2. 线上环境:去掉了所有针对开发者的代码,只保留让 React 能正常运行的部分,以优化大小和性能。

例如 React 源码中有大量类似下面这样的代码:

if (process.env.NODE_ENV !== 'production') {
  warning(false, '%s(...): Can only update a mounted or mounting component.... ')
}

如果你不定义 NODE_ENV=production 那么这些警告日志就会被包含到输出的代码中,输出的文件将会非常大。

process.env.NODE_ENV !== 'production' 中的 NODE_ENV'production' 两个值是社区的约定,通常使用这条判断语句在区分开发环境和线上环境。

压缩代码

浏览器从服务器访问网页时获取的 JavaScript、CSS 资源都是文本形式的,文件越大网页加载时间越长。 为了提升网页加速速度和减少网络传输流量,可以对这些资源进行压缩。 压缩的方法除了可以通过 GZIP 算法对文件压缩外,还可以对文本本身进行压缩。

对文本本身进行压缩的作用除了有提升网页加载速度的优势外,还具有混淆源码的作用。 由于压缩后的代码可读性非常差,就算别人下载到了网页的代码,也大大增加了代码分析和改造的难度。

下面来一一介绍如何在 Webpack 中压缩代码。

压缩 JavaScript

目前最成熟的 JavaScript 代码压缩工具是 UglifyJS , 它会分析 JavaScript 代码语法树,理解代码含义,从而能做到诸如去掉无效代码、去掉日志输出代码、缩短变量名等优化。

要在 Webpack 中接入 UglifyJS 需要通过插件的形式,目前有两个成熟的插件,分别是:

  • UglifyJsPlugin:通过封装 UglifyJS 实现压缩。
  • ParallelUglifyPlugin:多进程并行处理压缩,使用 ParallelUglifyPlugin 中有详细介绍。

由于 ParallelUglifyPlugin 在 4-4使用ParallelUglifyPlugin 中介绍过就不再复述, 这里重点介绍如何配置 UglifyJS 以达到最优的压缩效果。

UglifyJS 提供了非常多的选择用于配置在压缩过程中采用哪些规则,所有的选项说明可以在 其官方文档 上看到。 由于选项非常多,就挑出一些常用的拿出来详细讲解其应用方式:

  • sourceMap:是否为压缩后的代码生成对应的 Source Map,默认为不生成,开启后耗时会大大增加。一般不会把压缩后的代码的 Source Map 发送给网站用户的浏览器,而是用于内部开发人员调试线上代码时使用。
  • beautify: 是否输出可读性较强的代码,即会保留空格和制表符,默认为是,为了达到更好的压缩效果,可以设置为 false。
  • comments:是否保留代码中的注释,默认为保留,为了达到更好的压缩效果,可以设置为 false
  • compress.warnings:是否在 UglifyJs 删除没有用到的代码时输出警告信息,默认为输出,可以设置为 false 以关闭这些作用不大的警告。
  • drop_console:是否剔除代码中所有的 console 语句,默认为不剔除。开启后不仅可以提升代码压缩效果,也可以兼容不支持 console 语句 IE 浏览器。
  • collapse_vars:是否内嵌定义了但是只用到一次的变量,例如把 var x = 5; y = x 转换成 y = 5,默认为不转换。为了达到更好的压缩效果,可以设置为 false
  • reduce_vars: 是否提取出出现多次但是没有定义成变量去引用的静态值,例如把 x = 'Hello'; y = 'Hello' 转换成 var a = 'Hello'; x = a; y = b,默认为不转换。为了达到更好的压缩效果,可以设置为 false

也就是说,在不影响代码正确执行的前提下,最优化的代码压缩配置为如下:

const UglifyJSPlugin = require('webpack/lib/optimize/UglifyJsPlugin');

module.exports = {
  plugins: [
    // 压缩输出的 JS 代码
    new UglifyJSPlugin({
      compress: {
        // 在UglifyJs删除没有用到的代码时不输出警告
        warnings: false,
        // 删除所有的 `console` 语句,可以兼容ie浏览器
        drop_console: true,
        // 内嵌定义了但是只用到一次的变量
        collapse_vars: true,
        // 提取出出现多次但是没有定义成变量去引用的静态值
        reduce_vars: true,
      },
      output: {
        // 最紧凑的输出
        beautify: false,
        // 删除所有的注释
        comments: false,
      }
    }),
  ],
};

从以上配置中可以看出 Webpack 内置了 UglifyJsPlugin,需要指出的是 UglifyJsPlugin 当前采用的是 UglifyJS2 而不是老的 UglifyJS1, 这两个版本的 UglifyJS 在配置上有所区别,看文档时注意版本。

除此之外 Webpack 还提供了一个更简便的方法来接入 UglifyJSPlugin,直接在启动 Webpack 时带上 --optimize-minimize 参数,即 webpack --optimize-minimize, 这样 Webpack 会自动为你注入一个带有默认配置的 UglifyJSPlugin。

压缩 ES6

虽然当前大多数 JavaScript 引擎还不完全支持 ES6 中的新特性,但在一些特定的运行环境下已经可以直接执行 ES6 代码了,例如最新版的 Chrome、ReactNative 的引擎 JavaScriptCore。

运行 ES6 的代码相比于转换后的 ES5 代码有如下优点:

  • 一样的逻辑用 ES6 实现的代码量比 ES5 更少。
  • JavaScript 引擎对 ES6 中的语法做了性能优化,例如针对 const 申明的变量有更快的读取速度。

所以在运行环境允许的情况下,我们要尽可能的使用原生的 ES6 代码去运行,而不是转换后的 ES5 代码。

在你用上面所讲的压缩方法去压缩 ES6 代码时,你会发现 UglifyJS 会报错退出,原因是 UglifyJS 只认识 ES5 语法的代码。 为了压缩 ES6 代码,需要使用专门针对 ES6 代码的 UglifyES。

UglifyES 和 UglifyJS 来自同一个项目的不同分支,它们的配置项基本相同,只是接入 Webpack 时有所区别。 在给 Webpack 接入 UglifyES 时,不能使用内置的 UglifyJsPlugin,而是需要单独安装和使用最新版本的 uglifyjs-webpack-plugin。 安装方法如下:

npm i -D uglifyjs-webpack-plugin@beta

Webpack 相关配置代码如下:

<p data-height="465" data-theme-id="0" data-slug-hash="ELqbWw" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="Webpack" class="codepen">See the Pen Webpack by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

同时,为了不让 babel-loader 输出 ES5 语法的代码,需要去掉 .babelrc 配置文件中的 babel-preset-env,但是其它的 Babel 插件,比如 babel-preset-react 还是要保留, 因为正是 babel-preset-env 负责把 ES6 代码转换为 ES5 代码。

压缩 CSS

CSS 代码也可以像 JavaScript 那样被压缩,以达到提升加载速度和代码混淆的作用。 目前比较成熟可靠的 CSS 压缩工具是 cssnano,基于 PostCSS。

cssnano 能理解 CSS 代码的含义,而不仅仅是删掉空格,例如:

  • margin: 10px 20px 10px 20px 被压缩成 margin: 10px 20px
  • color: #ff0000 被压缩成 color:red

还有很多压缩规则可以去其官网查看,通常压缩率能达到 60%。

cssnano 接入到 Webpack 中也非常简单,因为 css-loader 已经将其内置了,要开启 cssnano 去压缩代码只需要开启 css-loaderminimize 选项。 相关 Webpack 配置如下:

<p data-height="565" data-theme-id="0" data-slug-hash="rvXYwm" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="cssnano" class="codepen">See the Pen cssnano by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

CDN 加速

虽然前面通过了压缩代码的手段来减小网络传输大小,但实际上最影响用户体验的还是网页首次打开时的加载等待。 导致这个问题的根本是网络传输过程耗时大,CDN 的作用就是加速网络传输。

CDN 又叫内容分发网络,通过把资源部署到世界各地,用户在访问时按照就近原则从离用户最近的服务器获取资源,从而加速资源的获取速度。 CDN 其实是通过优化物理链路层传输过程中的光速有限、丢包等问题来提升网速的,其大致原理可以如下:

在本节中你不必理解 CDN 的具体运行流程和实现原理,你可以简单的把 CDN 服务看作成速度更快的 HTTP 服务。 并且目前很多大公司都会建立自己的 CDN 服务,就算你自己没有资源去搭建一套 CDN 服务,各大云服务提供商都提供了按量收费的 CDN 服务。

接入 CDN

要给网站接入 CDN,需要把网页的静态资源上传到 CDN 服务上去,在服务这些静态资源的时候需要通过 CDN 服务提供的 URL 地址去访问。

举个详细的例子,有一个单页应用,构建出的代码结构如下:

dist
|-- app_9d89c964.js
|-- app_a6976b6d.css
|-- arch_ae805d49.png
`-- index.html

其中 index.html 内容如下:

<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
<script data-original="app_9d89c964.js"></script>
</body>
</html>

app_a6976b6d.css内容如下:

body{background:url(arch_ae805d49.png) repeat}h1{color:red}

可以看出到导入资源时都是通过相对路径去访问的,当把这些资源都放到同一个 CDN 服务上去时,网页是能正常使用的。 但需要注意的是由于 CDN 服务一般都会给资源开启很长时间的缓存,例如用户从 CDN 上获取到了 index.html 这个文件后, 即使之后的发布操作把 index.html 文件给重新覆盖了,但是用户在很长一段时间内还是运行的之前的版本,这会新的导致发布不能立即生效。

要避免以上问题,业界比较成熟的做法是这样的:

  • 针对 HTML 文件:不开启缓存,把 HTML 放到自己的服务器上,而不是 CDN 服务上,同时关闭自己服务器上的缓存。自己的服务器只提供 HTML 文件和数据接口。
  • 针对静态的 JavaScript、CSS、图片等文件:开启 CDN 和缓存,上传到 CDN 服务上去,同时给每个文件名带上由文件内容算出的 Hash 值, 例如上面的 app_a6976b6d.css 文件。 带上 Hash 值的原因是文件名会随着文件内容而变化,只要文件发生变化其对应的 URL 就会变化,它就会被重新下载,无论缓存时间有多长。

采用以上方案后,在 HTML 文件中的资源引入地址也需要换成 CDN 服务提供的地址,例如以上的 index.html 变为如下:

<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="//cdn.com/id/app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
<script data-original="//cdn.com/id/app_9d89c964.js"></script>
</body>
</html>

并且 app_a6976b6d.css 的内容也应该变为如下:

也就是说,之前的相对路径,都变成了绝对的指向 CDN 服务的 URL 地址。

如果你对形如 //cdn.com/id/app_a6976b6d.css 这样的 URL 感到陌生,你需要知道这种 URL 省掉了前面的 http: 或者 https: 前缀, 这样做的好处时在访问这些资源的时候会自动的根据当前 HTML 的 URL 是采用什么模式去决定是采用 HTTP 还是 HTTPS 模式。

除此之外,如果你还知道浏览器有一个规则是同一时刻针对同一个域名的资源并行请求是有限制的话(具体数字大概4个左右,不同浏览器可能不同), 你会发现上面的做法有个很大的问题。由于所有静态资源都放到了同一个 CDN 服务的域名下,也就是上面的 cdn.com。 如果网页的资源很多,例如有很多图片,就会导致资源的加载被阻塞,因为同时只能加载几个,必须等其它资源加载完才能继续加载。 要解决这个问题,可以把这些静态资源分散到不同的 CDN 服务上去, 例如把 JavaScript 文件放到 js.cdn.com 域名下、把 CSS 文件放到 css.cdn.com 域名下、图片文件放到 img.cdn.com 域名下, 这样做之后 index.html 需要变成这样:

<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="//css.cdn.com/id/app_a6976b6d.css">
</head>
<body>
<div id="app"></div>
<script data-original="//js.cdn.com/id/app_9d89c964.js"></script>
</body>
</html>
使用了多个域名后又会带来一个新问题:增加域名解析时间。是否采用多域名分散资源需要根据自己的需求去衡量得失。 当然你可以通过在 HTML HEAD 标签中 加入 <link rel="dns-prefetch" href="//js.cdn.com"> 去预解析域名,以降低域名解析带来的延迟。

用 Webpack 实现 CDN 的接入

总结上面所说的,构建需要实现以下几点:

  • 静态资源的导入 URL 需要变成指向 CDN 服务的绝对路径的 URL 而不是相对于 HTML 文件的 URL。
  • 静态资源的文件名称需要带上有文件内容算出来的 Hash 值,以防止被缓存。
  • 不同类型的资源放到不同域名的 CDN 服务上去,以防止资源的并行加载被阻塞。

先来看下要实现以上要求的最终 Webpack 配置:

<p data-height="565" data-theme-id="0" data-slug-hash="ELqbwb" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="CDN 的接入" class="codepen">See the Pen CDN 的接入 by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上代码中最核心的部分是通过 publicPath 参数设置存放静态资源的 CDN 目录 URL, 为了让不同类型的资源输出到不同的 CDN,需要分别在:

  • output.publicPath 中设置 JavaScript 的地址。
  • css-loader.publicPath 中设置被 CSS 导入的资源的的地址。
  • WebPlugin.stylePublicPath 中设置 CSS 文件的地址。

设置好 publicPath 后,WebPlugin 在生成 HTML 文件和 css-loader 转换 CSS 代码时,会考虑到配置中的 publicPath,用对应的线上地址替换原来的相对地址。

使用 Tree Shaking

Tree Shaking 可以用来剔除 JavaScript 中用不上的死代码。它依赖静态的 ES6 模块化语法,例如通过 importexport 导入导出。 Tree Shaking 最先在 Rollup 中出现,Webpack 在 2.0 版本中将其引入。

为了更直观的理解它,来看一个具体的例子。假如有一个文件 util.js 里存放了很多工具函数和常量,在 main.js 中会导入和使用 util.js,代码如下:

util.js 源码:

export function funcA() {
}

export function funB() {
}

main.js 源码:

import {funcA} from './util.js';
funcA();

Tree Shaking 后的 util.js

export function funcA() {
}

由于只用到了 util.js 中的 funcA,所以剩下的都被 Tree Shaking 当作死代码给剔除了。

需要注意的是要让 Tree Shaking 正常工作的前提是交给 Webpack 的 JavaScript 代码必须是采用 ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让 Webpack 可以简单的分析出哪些 export 的被 import 过了。 如果你采用 ES5 中的模块化,例如 module.export={...}require(x+y)if(x){require('./util')},Webpack 无法分析出哪些代码可以剔除。

接入 Tree Shaking

上面讲了 Tree Shaking 是做什么的,接下来一步步教你如何配置 Webpack 让 Tree Shaking 生效。

首先,为了把采用 ES6 模块化的代码交给 Webpack,需要配置 Babel 让其保留 ES6 模块化语句,修改 .babelrc 文件为如下:

{
  "presets": [
    [
      "env",
      {
        "modules": false
      }
    ]
  ]
}

其中 "modules": false 的含义是关闭 Babel 的模块转换功能,保留原本的 ES6 模块化语法。

配置好 Babel 后,重新运行 Webpack,在启动 Webpack 时带上 --display-used-exports 参数,以方便追踪 Tree Shaking 的工作, 这时你会发现在控制台中输出了如下的日志:

> webpack --display-used-exports
bundle.js  3.5 kB       0  [emitted]  main
   [0] ./main.js 41 bytes {0} [built]
   [1] ./util.js 511 bytes {0} [built]
       [only some exports used: funcA]

其中 [only some exports used: funcA] 提示了 util.js 只导出了用到的 funcA,说明 Webpack 确实正确的分析出了如何剔除死代码。

但当你打开 Webpack 输出的 bundle.js 文件看下时,你会发现用不上的代码还在里面,如下:

/* harmony export (immutable) */
__webpack_exports__["a"] = funcA;

/* unused harmony export funB */

function funcA() {
  console.log('funcA');
}

function funB() {
  console.log('funcB');
}

Webpack 只是指出了哪些函数用上了哪些没用上,要剔除用不上的代码还得经过 UglifyJS 去处理一遍。 要接入 UglifyJS 也很简单,不仅可以通过4-8压缩代码中介绍的加入 UglifyJSPlugin 去实现, 也可以简单的通过在启动 Webpack 时带上 --optimize-minimize 参数,为了快速验证 Tree Shaking 我们采用较简单的后者来实验下。

通过 webpack --display-used-exports --optimize-minimize 重启 Webpack 后,打开新输出的 bundle.js,内容如下:


function r() {
  console.log("funcA")
}

t.a = r

可以看出 Tree Shaking 确实做到了,用不上的代码都被剔除了。

当你的项目使用了大量第三方库时,你会发现 Tree Shaking 似乎不生效了,原因是大部分 Npm 中的代码都是采用的 CommonJS 语法, 这导致 Tree Shaking 无法正常工作而降级处理。 但幸运的时有些库考虑到了这点,这些库在发布到 Npm 上时会同时提供两份代码,一份采用 CommonJS 模块化语法,一份采用 ES6 模块化语法。 并且在 package.json 文件中分别指出这两份代码的入口。

redux 库为例,其发布到 Npm 上的目录结构为:

node_modules/redux
|-- es
|   |-- index.js # 采用 ES6 模块化语法
|-- lib
|   |-- index.js # 采用 ES5 模块化语法
|-- package.json

package.json 文件中有两个字段:

{
  "main": "lib/index.js", // 指明采用 CommonJS 模块化的代码入口
  "jsnext:main": "es/index.js" // 指明采用 ES6 模块化的代码入口
}

mainFields 用于配置采用哪个字段作为模块的入口描述。 为了让 Tree Shaking 对 redux 生效,需要配置 Webpack 的文件寻找规则为如下:

module.exports = {
  resolve: {
    // 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
};

以上配置的含义是优先使用 jsnext:main 作为入口,如果不存在 jsnext:main 就采用 browser 或者 main 作为入口。 虽然并不是每个 Npm 中的第三方模块都会提供 ES6 模块化语法的代码,但对于提供了的不能放过,能优化的就优化。

目前越来越多的 Npm 中的第三方模块考虑到了 Tree Shaking,并对其提供了支持。 采用 jsnext:main 作为 ES6 模块化代码的入口是社区的一个约定,假如将来你要发布一个库到 Npm 时,希望你能支持 Tree Shaking, 以让 Tree Shaking 发挥更大的优化效果,让更多的人为此受益。

提取公共代码

为什么需要提取公共代码

大型网站通常会由多个页面组成,每个页面都是一个独立的单页应用。 但由于所有页面都采用同样的技术栈,以及使用同一套样式代码,这导致这些页面之间有很多相同的代码。

如果每个页面的代码都把这些公共的部分包含进去,会造成以下问题:

  • 相同的资源被重复的加载,浪费用户的流量和服务器的成本;
  • 每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验。

如果把多个页面公共的代码抽离成单独的文件,就能优化以上问题。 原因是假如用户访问了网站的其中一个网页,那么访问这个网站下的其它网页的概率将非常大。 在用户第一次访问后,这些页面公共代码的文件已经被浏览器缓存起来,在用户切换到其它页面时,存放公共代码的文件就不会再重新加载,而是直接从缓存中获取。 这样做后有如下好处:

  • 减少网络传输流量,降低服务器成本;
  • 虽然用户第一次打开网站的速度得不到优化,但之后访问其它页面的速度将大大提升。

如何提取公共代码

你已经知道了提取公共代码会有什么好处,但是在实战中具体要怎么做,以达到效果最优呢? 通常你可以采用以下原则去为你的网站提取公共代码:

  • 根据你网站所使用的技术栈,找出网站所有页面都需要用到的基础库,以采用 React 技术栈的网站为例,所有页面都会依赖 reactreact-dom 等库,把它们提取到一个单独的文件。 一般把这个文件叫做 base.js,因为它包含所有网页的基础运行环境;
  • 在剔除了各个页面中被 base.js 包含的部分代码外,再找出所有页面都依赖的公共部分的代码提取出来放到 common.js 中去。
  • 再为每个网页都生成一个单独的文件,这个文件中不再包含 base.jscommon.js 中包含的部分,而只包含各个页面单独需要的部分代码。

文件之间的结构图如下:

读到这里你可以会有疑问:既然能找出所有页面都依赖的公共代码,并提取出来放到 common.js 中去,为什么还需要再把网站所有页面都需要用到的基础库提取到 base.js 去呢? 原因是为了长期的缓存 base.js 这个文件。

发布到线上的文件都会采用在4-9CDN加速中介绍过的方法,对静态文件的文件名都附加根据文件内容计算出 Hash 值,也就是最终 base.js 的文件名会变成 base_3b1682ac.js,以长期缓存文件。 网站通常会不断的更新发布,每次发布都会导致 common.js 和各个网页的 JavaScript 文件都会因为文件内容发生变化而导致其 Hash 值被更新,也就是缓存被更新。

把所有页面都需要用到的基础库提取到 base.js 的好处在于只要不升级基础库的版本,base.js 的文件内容就不会变化,Hash 值不会被更新,缓存就不会被更新。 每次发布浏览器都会使用被缓存的 base.js 文件,而不用去重新下载 base.js 文件。 由于 base.js 通常会很大,这对提升网页加速速度能起到很大的效果。

如何通过 Webpack 提取公共代码

你已经知道如何提取公共代码,接下来教你如何用 Webpack 实现。

Webpack 内置了专门用于提取多个 Chunk 中公共部分的插件 CommonsChunkPluginCommonsChunkPlugin 大致使用方法如下:

const CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin');

new CommonsChunkPlugin({
  // 从哪些 Chunk 中提取
  chunks: ['a', 'b'],
  // 提取出的公共部分形成一个新的 Chunk,这个新 Chunk 的名称
  name: 'common'
})

以上配置就能从网页 A 和网页 B 中抽离出公共部分,放到 common 中。

每个 CommonsChunkPlugin 实例都会生成一个新的 Chunk,这个新 Chunk 中包含了被提取出的代码,在使用过程中必须指定 name 属性,以告诉插件新生成的 Chunk 的名称。 其中 chunks 属性指明从哪些已有的 Chunk 中提取,如果不填该属性,则默认会从所有已知的 Chunk 中提取。

Chunk 是一系列文件的集合,一个 Chunk 中会包含这个 Chunk 的入口文件和入口文件依赖的文件。

通过以上配置输出的 common Chunk 中会包含所有页面都依赖的基础运行库 reactreact-dom,为了把基础运行库从 common 中抽离到 base 中去,还需要做一些处理。

首先需要先配置一个 Chunk,这个 Chunk 中只依赖所有页面都依赖的基础库以及所有页面都使用的样式,为此需要在项目中写一个文件 base.js 来描述 base Chunk 所依赖的模块,文件内容如下:

// 所有页面都依赖的基础库
import 'react';
import 'react-dom';
// 所有页面都使用的样式
import './base.css';

接着再修改 Webpack 配置,在 entry 中加入 base,相关修改如下:

module.exports = {
  entry: {
    base: './base.js'
  },
};

以上就完成了对新 Chunk base 的配置。

为了从 common 中提取出 base 也包含的部分,还需要配置一个 CommonsChunkPlugin,相关代码如下:

new CommonsChunkPlugin({
  // 从 common 和 base 两个现成的 Chunk 中提取公共的部分
  chunks: ['common', 'base'],
  // 把公共的部分放到 base 中
  name: 'base'
})

由于 commonbase 公共的部分就是 base 目前已经包含的部分,所以这样配置后 common 将会变小,而 base 将保持不变。

以上都配置好后重新执行构建,你将会得到四个文件,它们分别是:

base.js:所有网页都依赖的基础库组成的代码;
common.js:网页A、B都需要的,但又不在 base.js 文件中出现过的代码;
a.js:网页 A 单独需要的代码;
b.js:网页 B 单独需要的代码。
为了让网页正常运行,以网页 A 为例,你需要在其 HTML 中按照以下顺序引入以下文件才能让网页正常运行:

<script data-original="base.js"></script>
<script data-original="common.js"></script>
<script data-original="a.js"></script>

以上就完成了提取公共代码需要的所有步骤。

针对 CSS 资源,以上理论和方法同样有效,也就是说你也可以对 CSS 文件做同样的优化。

以上方法可能会出现 common.js 中没有代码的情况,原因是去掉基础运行库外很难再找到所有页面都会用上的模块。 在出现这种情况时,你可以采取以下做法之一:

  • CommonsChunkPlugin 提供一个选项 minChunks,表示文件要被提取出来时需要在指定的 Chunks 中最小出现最小次数。 假如 minChunks=2、chunks=['a','b','c','d'],任何一个文件只要在 ['a','b','c','d'] 中任意两个以上的 Chunk 中都出现过,这个文件就会被提取出来。 你可以根据自己的需求去调整 minChunks 的值,minChunks 越小越多的文件会被提取到 common.js 中去,但这也会导致部分页面加载的不相关的资源越多; minChunks 越大越少的文件会被提取到 common.js 中去,但这会导致 common.js 变小、效果变弱。
  • 根据各个页面之间的相关性选取其中的部分页面用 CommonsChunkPlugin 去提取这部分被选出的页面的公共部分,而不是提取所有页面的公共部分,而且这样的操作可以叠加多次。 这样做的效果会很好,但缺点是配置复杂,你需要根据页面之间的关系去思考如何配置,该方法不通用。
本实例提供项目完整代码

分割代码按需加载

为什么需要按需加载

随着互联网的发展,一个网页需要承载的功能越来越多。 对于采用单页应用作为前端架构的网站来说,会面临着一个网页需要加载的代码量很大的问题,因为许多功能都集中的做到了一个 HTML 里。 这会导致网页加载缓慢、交互卡顿,用户体验将非常糟糕。

导致这个问题的根本原因在于一次性的加载所有功能对应的代码,但其实用户每一阶段只可能使用其中一部分功能。 所以解决以上问题的方法就是用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载。

如何使用按需加载

在给单页应用做按需加载优化时,一般采用以下原则:

  • 把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类。
  • 把每一类合并为一个 Chunk,按需加载对应的 Chunk。
  • 对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的 Chunk 中,以降低用户能感知的网页加载时间。
  • 对于个别依赖大量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进行按需加载。

被分割出去的代码的加载需要一定的时机去触发,也就是当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。

由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,你可以预言用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。

用 Webpack 实现按需加载

Webpack 内置了强大的分割代码的功能去实现按需加载,实现起来非常简单。

举个例子,现在需要做这样一个进行了按需加载优化的网页:

  • 网页首次加载时只加载 main.js 文件,网页会展示一个按钮,main.js 文件中只包含监听按钮事件和加载按需加载的代码。
  • 当按钮被点击时才去加载被分割出去的 show.js 文件,加载成功后再执行 show.js 里的函数。

其中 main.js 文件内容如下:

window.document.getElementById('btn').addEventListener('click', function () {
  // 当按钮被点击后才去加载 show.js 文件,文件加载成功后执行文件导出的函数
  import(/* webpackChunkName: "show" */ './show').then((show) => {
    show('Webpack');
  })
});

show.js 文件内容如下:

module.exports = function (content) {
  window.alert('Hello ' + content);
};

代码中最关键的一句是 import(/* webpackChunkName: "show" */ './show'),Webpack 内置了对 import(*) 语句的支持,当 Webpack 遇到了类似的语句时会这样处理:

  • ./show.js 为入口新生成一个 Chunk;
  • 当代码执行到 import 所在语句时才会去加载由 Chunk 对应生成的文件。
  • import 返回一个 Promise,当文件加载成功时可以在 Promise 的 then 方法中获取到 show.js 导出的内容。
在使用 import() 分割代码后,你的浏览器并且要支持 Promise API 才能让代码正常运行, 因为 import() 返回一个 Promise,它依赖 Promise。对于不原生支持 Promise 的浏览器,你可以注入 Promise polyfill。

/* webpackChunkName: "show" */ 的含义是为动态生成的 Chunk 赋予一个名称,以方便我们追踪和调试代码。 如果不指定动态生成的 Chunk 的名称,默认名称将会是 [id].js/* webpackChunkName: "show" */ 是在 Webpack3 中引入的新特性,在 Webpack3 之前是无法为动态生成的 Chunk 赋予名称的。

为了正确的输出在 / webpackChunkName: "show" / 中配置的 ChunkName,还需要配置下 Webpack,配置如下:

module.exports = {
  // JS 执行入口文件
  entry: {
    main: './main.js',
  },
  output: {
    // 为从 entry 中配置生成的 Chunk 配置输出文件的名称
    filename: '[name].js',
    // 为动态加载的 Chunk 配置输出文件的名称
    chunkFilename: '[name].js',
  }
};

其中最关键的一行是 chunkFilename: '[name].js',,它专门指定动态生成的 Chunk 在输出时的文件名称。 如果没有这行,分割出的代码的文件名称将会是 [id].js

按需加载与 ReactRouter

在实战中,不可能会有上面那么简单的场景,接下来举一个实战中的例子:对采用了 ReactRouter 的应用进行按需加载优化。 这个例子由一个单页应用构成,这个单页应用由两个子页面构成,通过 ReactRouter 在两个子页面之间切换和管理路由。

这个单页应用的入口文件 main.js 如下:

<p data-height="565" data-theme-id="0" data-slug-hash="KROoWV" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="main.js" class="codepen">See the Pen main.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

以上代码中最关键的部分是 getAsyncComponent 函数,它的作用是配合 ReactRouter 去按需加载组件,具体含义请看代码中的注释。

由于以上源码需要通过 Babel 去转换后才能在浏览器中正常运行,需要在 Webpack 中配置好对应的 babel-loader,源码先交给 babel-loader 处理后再交给 Webpack 去处理其中的 import(*) 语句。 但这样做后你很快会发现一个问题:Babel 报出错误说不认识 import(*) 语法。 导致这个问题的原因是 import(*) 语法还没有被加入到在使用ES6语言中提到的 ECMAScript 标准中去, 为此我们需要安装一个 Babel 插件 babel-plugin-syntax-dynamic-import,并且将其加入到 .babelrc 中去:

{
  "presets": [
    "env",
    "react"
  ],
  "plugins": [
    "syntax-dynamic-import"
  ]
}

执行 Webpack 构建后,你会发现输出了三个文件:

  • main.js:执行入口所在的代码块,同时还包括 PageHome 所需的代码,因为用户首次打开网页时就需要看到 PageHome 的内容,所以不对其进行按需加载,以降低用户能感知到的加载时间;
  • page-about.js:当用户访问 /about 时才会加载的代码块;
  • page-login.js:当用户访问 /login 时才会加载的代码块。

同时你还会发现 page-about.jspage-login.js 这两个文件在首页是不会加载的,而是会当你切换到了对应的子页面后文件才会开始加载。

使用 Prepack

在前面的优化方法中提到了代码压缩和分块,这些都是在网络加载层面的优化,除此之外还可以优化代码在运行时的效率,Prepack 就是为此而生。

Prepack 由 Facebook 开源,它采用较为激进的方法:在保持运行结果一致的情况下,改变源代码的运行逻辑,输出性能更高的 JavaScript 代码。 实际上 Prepack 就是一个部分求值器,编译代码时提前将计算结果放到编译后的代码中,而不是在代码运行时才去求值。

以如下源码为例:

import React, {Component} from 'react';
import {renderToString} from 'react-dom/server';

function hello(name) {
  return 'hello ' + name;
}

class Button extends Component {
  render() {
    return hello(this.props.name);
  }
}

console.log(renderToString(<Button name='webpack'/>));

被 Prepack 转化后竟然直接输出如下:

console.log("hello webpack");

可以看出 Prepack 通过在编译阶段预先执行了源码得到执行结果,再直接把运行结果输出来以提升性能。

Prepack 的工作原理和流程大致如下:

  • 通过 Babel 把 JavaScript 源码解析成抽象语法树(AST),以方便更细粒度地分析源码;
  • Prepack 实现了一个 JavaScript 解释器,用于执行源码。借助这个解释器 Prepack 才能掌握源码具体是如何执行的,并把执行过程中的结果返回到输出中。

从表面上看去这似乎非常美好,但实际上 Prepack 还不够成熟与完善。Prepack 目前还处于初期的开发阶段,局限性也很大,例如:

  • 不能识别 DOM API 和 部分 Node.js API,如果源码中有调用依赖运行环境的 API 就会导致 Prepack 报错;
  • 存在优化后的代码性能反而更低的情况;
  • 存在优化后的代码文件尺寸大大增加的情况。

总之,现在把 Prepack 用于线上环境还为时过早。

接入 Webpack

Prepack 需要在 Webpack 输出最终的代码之前,对这些代码进行优化,就像 UglifyJS 那样。 因此需要通过新接入一个插件来为 Webpack 接入 Prepack,幸运的是社区中已经有人做好了这个插件:prepack-webpack-plugin

接入该插件非常简单,相关配置代码如下:

const PrepackWebpackPlugin = require('prepack-webpack-plugin').default;

module.exports = {
  plugins: [
    new PrepackWebpackPlugin()
  ]
};

重新执行构建你就会看到输出的被 Prepack 优化后的代码。

开启 Scope Hoisting

Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 "作用域提升",是在 Webpack3 中新推出的功能。 单从名字上看不出 Scope Hoisting 到底做了什么,下面来详细介绍它。

让我们先来看看在没有 Scope Hoisting 之前 Webpack 的打包方式。

假如现在有两个文件分别是 util.js:

export default 'Hello,Webpack';

和入口文件 main.js:

import str from './util.js';
console.log(str);

以上源码用 Webpack 打包后输出中的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);
    console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);
  }),
  (function (module, __webpack_exports__, __webpack_require__) {
    __webpack_exports__["a"] = ('Hello,Webpack');
  })
]

在开启 Scope Hoisting 后,同样的源码输出的部分代码如下:

[
  (function (module, __webpack_exports__, __webpack_require__) {
    var util = ('Hello,Webpack');
    console.log(util);
  })
]

从中可以看出开启 Scope Hoisting 后,函数申明由两个变成了一个,util.js 中定义的内容被直接注入到了 main.js 对应的模块中。 这样做的好处是:

  • 代码体积更小,因为函数申明语句会产生大量代码;
  • 代码在运行时因为创建的函数作用域更少了,内存开销也随之变小。

Scope Hoisting 的实现原理其实很简单:分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余。 因此只有那些被引用了一次的模块才能被合并。

由于 Scope Hoisting 需要分析出模块之间的依赖关系,因此源码必须采用 ES6 模块化语句,不然它将无法生效。

使用 Scope Hoisting

要在 Webpack 中使用 Scope Hoisting 非常简单,因为这是 Webpack 内置的功能,只需要配置一个插件,相关代码如下:

const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};

同时,考虑到 Scope Hoisting 依赖源码需采用 ES6 模块化语法,还需要配置 mainFields。 原因在 4-10 使用 TreeShaking 中提到过:因为大部分 Npm 中的第三方库采用了 CommonJS 语法,但部分库会同时提供 ES6 模块化的代码,为了充分发挥 Scope Hoisting 的作用,需要增加以下配置:

module.exports = {
  resolve: {
    // 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
};

对于采用了非 ES6 模块化语法的代码,Webpack 会降级处理不使用 Scope Hoisting 优化,为了知道 Webpack 对哪些代码做了降级处理, 你可以在启动 Webpack 时带上 --display-optimization-bailout 参数,这样在输出日志中就会包含类似如下的日志:

[0] ./main.js + 1 modules 80 bytes {0} [built]
    ModuleConcatenation bailout: Module is not an ECMAScript module
    

其中的 ModuleConcatenation bailout 告诉了你哪个文件因为什么原因导致了降级处理。

也就是说要开启 Scope Hoisting 并发挥最大作用的配置如下:

const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');

module.exports = {
  resolve: {
    // 针对 Npm 中的第三方模块优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },
  plugins: [
    // 开启 Scope Hoisting
    new ModuleConcatenationPlugin(),
  ],
};

输出分析

前面虽然介绍了非常多的优化方法,但这些方法也无法涵盖所有的场景,为此你需要对输出结果做分析,以决定下一步的优化方向。

最直接的分析方法就是去阅读 Webpack 输出的代码,但由于 Webpack 输出的代码可读性非常差而且文件非常大,这会让你非常头疼。 为了更简单直观的分析输出结果,社区中出现了许多可视化的分析工具。这些工具以图形的方式把结果更加直观的展示出来,让你快速看到问题所在。 接下来教你如何使用这些工具。

在启动 Webpack 时,支持两个参数,分别是:

  • --profile:记录下构建过程中的耗时信息;
  • --json:以 JSON 的格式输出构建结果,最后只输出一个 .json 文件,这个文件中包括所有构建相关的信息。

在启动 Webpack 时带上以上两个参数,启动命令如下 webpack --profile --json > stats.json,你会发现项目中多出了一个 stats.json 文件。 这个 stats.json 文件是给后面介绍的可视化分析工具使用的。

webpack --profile --json 会输出字符串形式的 JSON,> stats.json 是 UNIX/Linux 系统中的管道命令、含义是把 webpack --profile --json 输出的内容通过管道输出到 stats.json 文件中。

官方的可视化分析工具

Webpack 官方提供了一个可视化分析工具 Webpack Analyse,它是一个在线 Web 应用。

打开 Webpack Analyse 链接的网页后,你就会看到一个弹窗提示你上传 JSON 文件,也就是需要上传上面讲到的 stats.json 文件,如图:

Webpack Analyse 不会把你选择的 stats.json 文件发达到服务器,而是在浏览器本地解析,你不用担心自己的代码为此而泄露。 选择文件后,你马上就能如下的效果图:

它分为了六大板块,分别是:

  • Modules:展示所有的模块,每个模块对应一个文件。并且还包含所有模块之间的依赖关系图、模块路径、模块ID、模块所属 Chunk、模块大小;
  • Chunks:展示所有的代码块,一个代码块中包含多个模块。并且还包含代码块的ID、名称、大小、每个代码块包含的模块数量,以及代码块之间的依赖关系图;
  • Assets:展示所有输出的文件资源,包括 .js.css、图片等。并且还包括文件名称、大小、该文件来自哪个代码块;
  • Warnings:展示构建过程中出现的所有警告信息;
  • Errors:展示构建过程中出现的所有错误信息;
  • Hints:展示处理每个模块的过程中的耗时。

下面以在 3-10管理多个单页应用 中使用的项目为例,来分析其 stats.json 文件。

点击 Modules,查看模块信息,效果图如下:

由于依赖了大量第三方模块,文件数量大,导致模块之间的依赖关系图太密集而无法看清,但你可以进一步放大查看。

点击 Chunks,查看代码块信息,效果图如下:

由代码块之间的依赖关系图可以看出两个页面级的代码块 loginindex 依赖提取出来的公共代码块 common。

点击 Assets,查看输出的文件资源,效果图如下:

点击 Hints,查看输出过程中的耗时分布,效果图如下:

从 Hints 可以看出每个文件在处理过程的开始时间和结束时间,从而可以找出是哪个文件导致构建缓慢。

webpack-bundle-analyzer

webpack-bundle-analyzer 是另一个可视化分析工具, 它虽然没有官方那样有那么多功能,但比官方的要更加直观。

先来看下它的效果图:

它能方便的让你知道:

  • 打包出的文件中都包含了什么;
  • 每个文件的尺寸在总体中的占比,一眼看出哪些文件尺寸大;
  • 模块之间的包含关系;
  • 每个文件的 Gzip 后的大小。

接入 webpack-bundle-analyzer 的方法很简单,步骤如下:

  1. 安装 webpack-bundle-analyzer 到全局,执行命令 npm i -g webpack-bundle-analyzer
  2. 按照上面提到的方法生成 stats.json 文件;
  3. 在项目根目录中执行 webpack-bundle-analyzer 后,浏览器会打开对应网页看到以上效果。

优化总结

本章从开发体验和输出质量两个角度讲解了如何优化项目中的 Webpack 配置,这些优化的方法都是来自项目实战中的经验积累。 虽然每一小节都是一个个独立的优化方法,但是有些优化方法并不冲突可以相互组合,以达到最佳的效果。

以下将给出是结合了本章所有优化方法的实例项目,由于构建速度和输出质量不能兼得,按照开发环境和线上环境为该项目配置了两份文件,分别如下:

侧重优化开发体验的配置文件 webpack.config.js

<p data-height="565" data-theme-id="0" data-slug-hash="pVMVgW" data-default-tab="js" data-user="whjin" data-embed-version="2" data-pen-title="webpack-dist.config.js" class="codepen">See the Pen webpack-dist.config.js by whjin (@whjin) on CodePen.</p>
<script async data-original="https://static.codepen.io/ass...;></script>

本章介绍的优化方法虽然难以涵盖 Webpack 的方方面面,但足以解决实战中常见的场景。 对于本书没有介绍到的场景,你需要根据自己的需求按照以下思路去优化:

  1. 找出问题的原因;
  2. 找出解决问题的方法;
  3. 寻找解决问题方法对应的 Webpack 集成方案。

同时你还需要跟紧社区的迭代,学习他人的优化方法,了解最新的 Webpack 特性和新涌现出的插件、Loader。

查看原文

认证与成就

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

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-07-29
个人主页被 358 人浏览