Yourz Notes


  • 首页

  • 分类

  • 关于

  • 归档

  • 标签

网站性能优化

发表于 2017-12-01 | 分类于 基础总结

本文主要介绍页面加载相关的三个部分:加载流程解析,影响性能的因素,优化的方法

加载流程

流程简介

  • 对用户来说,访问页面相当简单:

    1
    打开浏览器 --> 输入网址 --> 浏览页面
  • 但对于浏览器来说,它与服务端之间的交互要复杂的多。通过浏览器的api(window.performance.timing),我们可以详细的画出一个请求的完成流程:

图1

  • 图1显示的是完整的加载流程,事件基本按所示顺序触发
    • domLoading可能在ResponseEnd之前触发
    • unload有可能在ResponseStart之前触发
    • 部分事件是可选的,默认值为0。如Redirect、Unload和secureConnectionStart(红色),实际请求时有可能不会触发
    • redirectStart/redirectEnd:没有重定向时,这两个字段值均为0
    • secureConnectionStart:非HTTPS时,字段值为0
    • unloadEventStart/unloadEventEnd:如果之前没有同源的document,字段值为0
    • 部分模块(浅蓝)事件有起止点,其他模块只有起点

流程解析

完整的流程可以分为两个阶段:请求和渲染

  • 请求阶段:从开始请求到获取服务端数据。简单来说就是图1中的首字节,或者再加上response。核心流程包含五部分:
    • 重定向:重定向后需要加载新页面。对浏览器来说,新页面加载时间从redirectStart开始。对用户来说,显示时间为重定向前后页面之和
    • 缓存:浏览器已缓存的资源,不用重新发网络请求,直接从缓存中拉取
    • DNS:域名解析,可以缓存
    • TCP:建立TCP连接
    • Request:未有效缓存或新资源,向服务端发起请求
  • 渲染阶段:浏览器接收数据后的处理,最终呈现用户可见页面。可以理解为domLoading事件之后的阶段,先后顺序依次为:
    • Dom就绪:Html解析,构建DOM Tree。触发domInteractive
    • Dom和Cssom就绪:DCL(domContentLoaded),开始构建Render Tree。触发domContentLoadedEventStart,domContentLoadedEventEnd所有事件绑定完成
    • 所有资源就绪:页面上所有资源加载完成,包含图片、视频等。触发domComplete

渲染阶段在webkit浏览器(chrome & safari)中实际执行流程如图2所示:

图2

  • 可以看到,HTML和CSS的解析是同步进行的。Render Tree会在DOM Tree后立即触发,表现为domContentLoadedEventStart - domInteractive的差值很小。
  • 但是,由于JS可能操作HTML,故会中断DOM Tree的构建。JS在执行时,有时会依赖样式。所以JS会减慢Dom的解析,JS需要等待CSSOM的解析,如图3所示。为了解决这种问题,有两种方法可以延迟JS的执行:
    图3
    • JS设置为defer,domInteractive不需等待JS而正常触发,但会在DCL触发之前被执行
    • JS设置为async,domInteractive和DCL都不需等待JS的执行

但无论JS是defer或者async,都需要保证JS中不执行doc.write,并且CSSOM在JS执行之前构建。

性能因素

  • 浏览器
    • 请求处理
    • 渲染
    • 缓存
  • 网络
    • 状态
    • 协议
    • 传输内容
  • 服务端
    • 并发
    • 请求响应
  • 编码

性能监控

  • 浏览器的api查看各个事件触发的时间
    1
    2
    performance.timing
    performance.getEntriesByType("navigation")[0] // new api

以上获取的都是时间戳,通过简单的计算,可得到实际耗时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var res = {};
var timing = window.performance.timing;
var start = timing['navigationStart'] || 0;
for (var key in timing) {
if (typeof timing[key] == 'number') {
if (timing[key] === 0) {
switch(key) {
case 'redirectStart' :
console.log('无重定向');
break;
case 'unloadEventStart' :
console.log('没有unload事件:直接输入url或从其他域跳转');
break;
case 'secureConnectionStart' :
console.log('非HTTPS请求');
break;
}
} else {
res[key] = timing[key] - start;
}
}
}

// print in sequence
while(true) {
var key = 'loadEventEnd';
var min = res[key];
for (var i in res) {
if (res[i] < min) {
min = res[i];
key = i;
}
}
console.log(`${key} : ${res[key]}`)
delete res[key];
if (key == 'loadEventEnd') break;
}

优化方式

针对影响网站服务的各种因素和限制,分别优化:

减少请求次数

  • 合并静态文件css、js等
  • 雪碧图 VS 内联图片(base64)
    • 内容有所变动,雪碧图就要全部调整
    • 内联图片不能缓存
  • 避免重定向
    • 重定向可以跟踪出站流量 http://xxx.com?url=jump_url
    • 响应头部添加Strict-Transport-Security: max-age=31536000,可避免http -> https的重定向
  • 使用内联脚本、样式
    • 主页可用
    • 脚本或样式复用率低,可用
    • 复用率高的页面建议使用外部脚本或样式
    • 组件即可内联,又可复用(React,Vue等)
  • 浏览器缓存

    • 缓存使用示例

      图4

    • 常用缓存字段列表

      request | response | method | content | comment
      —|—|—|—|—
      If-Modified-Since: time1 | Last-Modified: time2 | get or head | Tue, 12 Sep 2017 08:59:08 GMT | 是否过期,没过期返回304,会被If-None-Match覆盖
      If-None-Match | ETag | | 指纹 | 资源无变化,GET或HEAD返回304,其他方法返回412;If-Match PUT方法用来更新资源
      | Expires| | Tue, 12 Sep 2017 08:59:08 GMT | 某个时间后失效,有效期内直接使用缓存。需要和服务端对时,会被Cache-Control:max-age覆盖
      Cache-Control | Cache-Control|
      | must_revalidate, public, max-age=3600 | no-cache,no-stroe,max-age(一段周期后失效)等,在http 1.1引入

    • 合理设置缓存

      图5

减小请求文件

  • 支持压缩(传输,浏览器解压)
    • Request: Accept-Encoding:gzip, deflate, sdch, br
    • Response: Content-Encoding:gzip
    • Response: vary: Accept-Encoding(代理商)
  • 压缩静态资源文件
  • 使用合适的图片

缩短请求路径

  • CDN(内容分发网络)
  • 减少DNS查找(域名尽量少)
  • 使用HTTP,而不是HTTPS(安全性)
  • 长连接 (connection:keep-alive)
    • 同一TCP连接,可以发送多个请求
    • Transfer-Encoding: chunked,chunked长度为0,本次连接终止

节省渲染时间

  • 样式
    • 样式放在顶部(DCL:防止闪烁、白屏等)
    • 少使用@import,会同步导入css文件
    • 避免css表达式
  • 脚本
    • 脚本放在底部,会阻塞其他资源的下载(白屏等)
    • 按需加载

请求并发

  • Domain sharding
    • 浏览器只能对同一Domain最多请求6~8个连接,资源分散(DNS查找)
  • Pipelining
    • 同时发送多个请求,但是服务端依然需要依次响应

http2

  • 简介(移步查看更多http2)
    • 压缩header,header不再使用字段,使用位标识
    • 服务端推送
    • frame和stream
      • 每个请求或应答相当于一个stream(id:奇数为客户端)
      • 响应数据(stream)切成多个帧frame(所属stream id),可交叉传送
    • 流优先级和依赖

参考

  • [浏览器工作原理]:https://www.html5rocks.com/zh/tutorials/internals/howbrowserswork/
  • [DCL]:https://calendar.perfplanet.com/2012/deciphering-the-critical-rendering-path/
  • [HTTP headers]:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/If-Match

Vuex框架原理与源码分析

发表于 2017-10-02 | 分类于 架构相关

Vuex是一个专为Vue服务,用于管理页面数据状态、提供统一数据操作的生态系统。它集中于MVC模式中的Model层,规定所有的数据操作必须通过 action - mutation - state change 的流程来进行,再结合Vue的数据视图双向绑定特性来实现页面的展示更新。统一的页面状态管理以及操作处理,可以让复杂的组件交互变得简单清晰,同时可在调试模式下进行时光机般的倒退前进操作,查看数据改变过程,使code debug更加方便。

最近在开发的项目中用到了Vuex来管理整体页面状态,遇到了很多问题。决定研究下源码,在答疑解惑之外,能深入学习其实现原理。

先将问题抛出来,使学习和研究更有针对性:

  1. 使用Vuex只需执行 Vue.use(Vuex),并在Vue的配置中传入一个store对象的示例,store是如何实现注入的?
  2. state内部是如何实现支持模块配置和模块嵌套的?
  3. 在执行dispatch触发action(commit同理)的时候,只需传入(type, payload),action执行函数中第一个参数store从哪里获取的?
  4. 如何区分state是外部直接修改,还是通过mutation方法修改的?
  5. 调试时的“时空穿梭”功能是如何实现的?

注:本文对有Vuex有实际使用经验的同学帮助更大,能更清晰理解Vuex的工作流程和原理,使用起来更得心应手。初次接触的同学,可以先参考Vuex官方文档进行基础概念的学习。

一、框架核心流程

进行源码分析之前,先了解一下官方文档中提供的核心思想图,它也代表着整个Vuex框架的运行流程。
vuex-core
如图示,Vuex为Vue Components建立起了一个完整的生态圈,包括开发中的API调用一环。围绕这个生态圈,简要介绍一下各模块在核心流程中的主要功能:

  • Vue Components:Vue组件。HTML页面上,负责接收用户操作等交互行为,执行dispatch方法触发对应action进行回应。
  • dispatch:操作行为触发方法,是唯一能执行action的方法。
  • actions:操作行为处理模块。负责处理Vue Components接收到的所有交互行为。包含同步/异步操作,支持多个同名方法,按照注册的顺序依次触发。向后台API请求的操作就在这个模块中进行,包括触发其他action以及提交mutation的操作。该模块提供了Promise的封装,以支持action的链式触发。
  • commit:状态改变提交操作方法。对mutation进行提交,是唯一能执行mutation的方法。
  • mutations:状态改变操作方法。是Vuex修改state的唯一推荐方法,其他修改方式在严格模式下将会报错。该方法只能进行同步操作,且方法名只能全局唯一。操作之中会有一些hook暴露出来,以进行state的监控等。
  • state:页面状态管理容器对象。集中存储Vue components中data对象的零散数据,全局唯一,以进行统一的状态管理。页面显示所需的数据从该对象中进行读取,利用Vue的细粒度数据响应机制来进行高效的状态更新。
  • getters:state对象读取方法。图中没有单独列出该模块,应该被包含在了render中,Vue Components通过该方法读取全局state对象。

Vue组件接收交互行为,调用dispatch方法触发action相关处理,若页面状态需要改变,则调用commit方法提交mutation修改state,通过getters获取到state新值,重新渲染Vue Components,界面随之更新。

二、目录结构介绍

打开Vuex项目,看下源码目录结构。

dir_structure

Vuex提供了非常强大的状态管理功能,源码代码量却不多,目录结构划分也很清晰。先大体介绍下各个目录文件的功能:

  • module:提供module对象与module对象树的创建功能;
  • plugins:提供开发辅助插件,如“时光穿梭”功能,state修改的日志记录功能等;
  • helpers.js:提供action、mutations以及getters的查找API;
  • index.js:是源码主入口文件,提供store的各module构建安装;
  • mixin.js:提供了store在Vue实例上的装载注入;
  • util.js:提供了工具方法如find、deepCopy、forEachValue以及assert等方法。

三、初始化装载与注入

了解大概的目录及对应功能后,下面开始进行源码分析。index.js中包含了所有的核心代码,从该文件入手进行分析。

3.1 装载实例

先看个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* store.js文件
* 创建store对象,配置state、action、mutation以及getter
*
**/

import Vue from 'vue'
import Vuex from 'vuex'

// install Vuex框架
Vue.use(Vuex)

// 创建并导出store对象。为了方便,不配置任何参数
export default new Vuex.Store()

store.js文件中,加载Vuex框架,创建并导出一个空配置的store对象实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* vue-index.js文件
*
*
**/

import Vue from 'vue'
import App from './../pages/app.vue'
import store from './store.js'

new Vue({
el: '#root',
router,
store,
render: h => h(App)
})

然后在index.js中,正常初始化一个页面根级别的Vue组件,传入这个自定义的store对象。

如问题1所述,以上实例除了Vue的初始化代码,只是多了一个store对象的传入。一起看下源码中的实现方式。

3.2 装载分析

index.js文件代码执行开头,定义局部 Vue 变量,用于判断是否已经装载和减少全局作用域查找。

1
let Vue

然后判断若处于浏览器环境下且加载过Vue,则执行install方法。

1
2
3
4
// auto install in dist mode  
if (typeof window !== 'undefined' && window.Vue) {
install(window.Vue)
}

install方法将Vuex装载到Vue对象上,Vue.use(Vuex) 也是通过它执行,先看下Vue.use方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function (plugin: Function | Object) {
/* istanbul ignore if */
if (plugin.installed) {
return
}
// additional parameters
const args = toArray(arguments, 1)
args.unshift(this)
if (typeof plugin.install === 'function') {
// 实际执行插件的install方法
plugin.install.apply(plugin, args)
} else {
plugin.apply(null, args)
}
plugin.installed = true
return this
}

若是首次加载,将局部Vue变量赋值为全局的Vue对象,并执行applyMixin方法,install实现如下:

1
2
3
4
5
6
7
8
9
10
function install (_Vue) {
if (Vue) {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
)
return
}
Vue = _Vue
applyMixin(Vue)
}

来看下applyMixin方法内部代码。如果是2.x.x以上版本,可以使用 hook 的形式进行注入,或使用封装并替换Vue对象原型的_init方法,实现注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function (Vue) {
const version = Number(Vue.version.split('.')[0])

if (version >= 2) {
const usesInit = Vue.config._lifecycleHooks.indexOf('init') > -1
Vue.mixin(usesInit ? { init: vuexInit } : { beforeCreate: vuexInit })
} else {
// override init and inject vuex init procedure
// for 1.x backwards compatibility.
const _init = Vue.prototype._init
Vue.prototype._init = function (options = {}) {
options.init = options.init
? [vuexInit].concat(options.init)
: vuexInit
_init.call(this, options)
}
}

具体实现:将初始化Vue根组件时传入的store设置到this对象的$store属性上,子组件从其父组件引用$store属性,层层嵌套进行设置。在任意组件中执行 this.$store 都能找到装载的那个store对象,vuexInit方法实现如下:

1
2
3
4
5
6
7
8
9
function vuexInit () {
const options = this.$options
// store injection
if (options.store) {
this.$store = options.store
} else if (options.parent && options.parent.$store) {
this.$store = options.parent.$store
}
}

看个图例理解下store的传递。

页面Vue结构图:
cart_vue_structure

对应store流向:
cart_vue_structure

四、store对象构造

上面对Vuex框架的装载以及注入自定义store对象进行分析,解决了问题1。接下来详细分析store对象的内部功能和具体实现,来解答 为什么actions、getters、mutations中能从arguments[0]中拿到store的相关数据? 等问题。

store对象实现逻辑比较复杂,先看下构造方法的整体逻辑流程来帮助后面的理解:

cart_vue_structure

4.1 环境判断

开始分析store的构造函数,分小节逐函数逐行的分析其功能。

1
2
3
constructor (options = {}) {
assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)
assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)

在store构造函数中执行环境判断,以下都是Vuex工作的必要条件:

  1. 已经执行安装函数进行装载;
  2. 支持Promise语法。

assert函数是一个简单的断言函数的实现,一行代码即可实现。

1
2
3
function assert (condition, msg) {
if (!condition) throw new Error(`[vuex] ${msg}`)
}

4.2 数据初始化、module树构造

环境判断后,根据new构造传入的options或默认值,初始化内部数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const {
state = {},
plugins = [],
strict = false
} = options

// store internal state
this._committing = false // 是否在进行提交状态标识
this._actions = Object.create(null) // acitons操作对象
this._mutations = Object.create(null) // mutations操作对象
this._wrappedGetters = Object.create(null) // 封装后的getters集合对象
this._modules = new ModuleCollection(options) // Vuex支持store分模块传入,存储分析后的modules
this._modulesNamespaceMap = Object.create(null) // 模块命名空间map
this._subscribers = [] // 订阅函数集合,Vuex提供了subscribe功能
this._watcherVM = new Vue() // Vue组件用于watch监视变化

调用 new Vuex.store(options) 时传入的options对象,用于构造ModuleCollection类,下面看看其功能。

1
2
3
4
5
6
7
8
9
10
constructor (rawRootModule) {
// register root module (Vuex.Store options)
this.root = new Module(rawRootModule, false)

// register all nested modules
if (rawRootModule.modules) {
forEachValue(rawRootModule.modules, (rawModule, key) => {
this.register([key], rawModule, false)
})
}

ModuleCollection主要将传入的options对象整个构造为一个module对象,并循环调用 this.register([key], rawModule, false) 为其中的modules属性进行模块注册,使其都成为module对象,最后options对象被构造成一个完整的组件树。ModuleCollection类还提供了modules的更替功能,详细实现可以查看源文件module-collection.js。

4.3 dispatch与commit设置

继续回到store的构造函数代码。

1
2
3
4
5
6
7
8
9
10
11
// bind commit and dispatch to self
const store = this
const { dispatch, commit } = this

this.dispatch = function boundDispatch (type, payload) {
return dispatch.call(store, type, payload)
}

this.commit = function boundCommit (type, payload, options) {
return commit.call(store, type, payload, options)
}

封装替换原型中的dispatch和commit方法,将this指向当前store对象。dispatch和commit方法具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
dispatch (_type, _payload) {
// check object-style dispatch
const {
type,
payload
} = unifyObjectStyle(_type, _payload) // 配置参数处理

// 当前type下所有action处理函数集合
const entry = this._actions[type]
if (!entry) {
console.error(`[vuex] unknown action type: ${type}`)
return
}
return entry.length > 1
? Promise.all(entry.map(handler => handler(payload)))
: entry[0](payload)
}

前面提到,dispatch的功能是触发并传递一些参数(payload)给对应type的action。因为其支持2种调用方法,所以在dispatch中,先进行参数的适配处理,然后判断action type是否存在,若存在就逐个执行(注:上面代码中的this._actions[type] 以及 下面的 this._mutations[type] 均是处理过的函数集合,具体内容留到后面进行分析)。

commit方法和dispatch相比虽然都是触发type,但是对应的处理却相对复杂,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
commit (_type, _payload, _options) {
// check object-style commit
const {
type,
payload,
options
} = unifyObjectStyle(_type, _payload, _options)

const mutation = { type, payload }
const entry = this._mutations[type]
if (!entry) {
console.error(`[vuex] unknown mutation type: ${type}`)
return
}
// 专用修改state方法,其他修改state方法均是非法修改
this._withCommit(() => {
entry.forEach(function commitIterator (handler) {
handler(payload)
})
})

// 订阅者函数遍历执行,传入当前的mutation对象和当前的state
this._subscribers.forEach(sub => sub(mutation, this.state))

if (options && options.silent) {
console.warn(
`[vuex] mutation type: ${type}. Silent option has been removed. ` +
'Use the filter functionality in the vue-devtools'
)
}
}

该方法同样支持2种调用方法。先进行参数适配,判断触发mutation type,利用_withCommit方法执行本次批量触发mutation处理函数,并传入payload参数。执行完成后,通知所有_subscribers(订阅函数)本次操作的mutation对象以及当前的state状态,如果传入了已经移除的silent选项则进行提示警告。

4.4 state修改方法

_withCommit是一个代理方法,所有触发mutation的进行state修改的操作都经过它,由此来统一管理监控state状态的修改。实现代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

_withCommit (fn) {
// 保存之前的提交状态
const committing = this._committing

// 进行本次提交,若不设置为true,直接修改state,strict模式下,Vuex将会产生非法修改state的警告
this._committing = true

// 执行state的修改操作
fn()

// 修改完成,还原本次修改之前的状态
this._committing = committing
}

缓存执行时的committing状态将当前状态设置为true后进行本次提交操作,待操作完毕后,将committing状态还原为之前的状态。

4.5 module安装

绑定dispatch和commit方法之后,进行严格模式的设置,以及模块的安装(installModule)。由于占用资源较多影响页面性能,严格模式建议只在开发模式开启,上线后需要关闭。

1
2
3
4
5
6
7
// strict mode
this.strict = strict

// init root module.
// this also recursively registers all sub-modules
// and collects all module getters inside this._wrappedGetters
installModule(this, state, [], this._modules.root)

4.5.1 初始化rootState

上述代码的备注中,提到installModule方法初始化组件树根组件、注册所有子组件,并将其中所有的getters存储到this._wrappedGetters属性中,让我们看看其中的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function installModule (store, rootState, path, module, hot) {
const isRoot = !path.length
const namespace = store._modules.getNamespace(path)

// register in namespace map
if (namespace) {
store._modulesNamespaceMap[namespace] = module
}

// 非根组件设置 state 方法
if (!isRoot && !hot) {
const parentState = getNestedState(rootState, path.slice(0, -1))
const moduleName = path[path.length - 1]
store._withCommit(() => {
Vue.set(parentState, moduleName, module.state)
})
}

······

判断是否是根目录,以及是否设置了命名空间,若存在则在namespace中进行module的存储,在不是根组件且不是 hot 条件的情况下,通过getNestedState方法拿到该module父级的state,拿到其所在的 moduleName ,调用 Vue.set(parentState, moduleName, module.state) 方法将其state设置到父级state对象的moduleName属性中,由此实现该模块的state注册(首次执行这里,因为是根目录注册,所以并不会执行该条件中的方法)。getNestedState方法代码很简单,分析path拿到state,如下。

1
2
3
4
5
function getNestedState (state, path) {
return path.length
? path.reduce((state, key) => state[key], state)
: state
}

4.5.2 module上下文环境设置

1
const local = module.context = makeLocalContext(store, namespace, path)

命名空间和根目录条件判断完毕后,接下来定义local变量和module.context的值,执行makeLocalContext方法,为该module设置局部的 dispatch、commit方法以及getters和state(由于namespace的存在需要做兼容处理)。

4.5.3 mutations、actions以及getters注册

定义local环境后,循环注册我们在options中配置的action以及mutation等。逐个分析各注册函数之前,先看下模块间的逻辑关系流程图:

complete_flow

下面分析代码逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 注册对应模块的mutation,供state修改使用
module.forEachMutation((mutation, key) => {
const namespacedType = namespace + key
registerMutation(store, namespacedType, mutation, local)
})

// 注册对应模块的action,供数据操作、提交mutation等异步操作使用
module.forEachAction((action, key) => {
const namespacedType = namespace + key
registerAction(store, namespacedType, action, local)
})

// 注册对应模块的getters,供state读取使用
module.forEachGetter((getter, key) => {
const namespacedType = namespace + key
registerGetter(store, namespacedType, getter, local)
})

registerMutation方法中,获取store中的对应mutation type的处理函数集合,将新的处理函数push进去。这里将我们设置在mutations type上对应的 handler 进行了封装,给原函数传入了state。在执行 commit('xxx', payload) 的时候,type为 xxx 的mutation的所有handler都会接收到state以及payload,这就是在handler里面拿到state的原因。

1
2
3
4
5
6
7
8
9
function registerMutation (store, type, handler, local) {
// 取出对应type的mutations-handler集合
const entry = store._mutations[type] || (store._mutations[type] = [])
// commit实际调用的不是我们传入的handler,而是经过封装的
entry.push(function wrappedMutationHandler (payload) {
// 调用handler并将state传入
handler(local.state, payload)
})
}

action和getter的注册也是同理的,看一下代码(注:前面提到的 this.actions 以及 this.mutations在此处进行设置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function registerAction (store, type, handler, local) {
// 取出对应type的actions-handler集合
const entry = store._actions[type] || (store._actions[type] = [])
// 存储新的封装过的action-handler
entry.push(function wrappedActionHandler (payload, cb) {
// 传入 state 等对象供我们原action-handler使用
let res = handler({
dispatch: local.dispatch,
commit: local.commit,
getters: local.getters,
state: local.state,
rootGetters: store.getters,
rootState: store.state
}, payload, cb)
// action需要支持promise进行链式调用,这里进行兼容处理
if (!isPromise(res)) {
res = Promise.resolve(res)
}
if (store._devtoolHook) {
return res.catch(err => {
store._devtoolHook.emit('vuex:error', err)
throw err
})
} else {
return res
}
})
}

function registerGetter (store, type, rawGetter, local) {
// getters只允许存在一个处理函数,若重复需要报错
if (store._wrappedGetters[type]) {
console.error(`[vuex] duplicate getter key: ${type}`)
return
}

// 存储封装过的getters处理函数
store._wrappedGetters[type] = function wrappedGetter (store) {
// 为原getters传入对应状态
return rawGetter(
local.state, // local state
local.getters, // local getters
store.state, // root state
store.getters // root getters
)
}
}

action handler比mutation handler以及getter wrapper多拿到dispatch和commit操作方法,因此action可以进行dispatch action和commit mutation操作。

4.5.4 子module安装

注册完了根组件的actions、mutations以及getters后,递归调用自身,为子组件注册其state,actions、mutations以及getters等。

1
2
3
module.forEachChild((child, key) => {
installModule(store, rootState, path.concat(key), child, hot)
})

4.5.5 实例结合

前面介绍了dispatch和commit方法以及actions等的实现,下面结合一个官方的购物车实例中的部分代码来加深理解。

Vuex配置代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/
* store-index.js store配置文件
*
/

import Vue from 'vue'
import Vuex from 'vuex'
import * as actions from './actions'
import * as getters from './getters'
import cart from './modules/cart'
import products from './modules/products'
import createLogger from '../../../src/plugins/logger'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

export default new Vuex.Store({
actions,
getters,
modules: {
cart,
products
},
strict: debug,
plugins: debug ? [createLogger()] : []
})

Vuex组件module中各模块state配置代码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* cart.js
*
**/

const state = {
added: [],
checkoutStatus: null
}

/**
* products.js
*
**/

const state = {
all: []
}

加载上述配置后,页面state结构如下图:

cart_state

state中的属性配置都是按照option配置中module path的规则来进行的,下面看action的操作实例。

Vuecart组件代码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Cart.vue 省略template代码,只看script部分
*
**/

export default {
methods: {
// 购物车中的购买按钮,点击后会触发结算。源码中会调用 dispatch方法
checkout (products) {
this.$store.dispatch('checkout', products)
}
}
}

Vuexcart.js组件action配置代码部分:

1
2
3
4
5
6
7
8
9
10
11
const actions = {
checkout ({ commit, state }, products) {
const savedCartItems = [...state.added] // 存储添加到购物车的商品
commit(types.CHECKOUT_REQUEST) // 设置提交结算状态
shop.buyProducts( // 提交api请求,并传入成功与失败的cb-func
products,
() => commit(types.CHECKOUT_SUCCESS), // 请求返回成功则设置提交成功状态
() => commit(types.CHECKOUT_FAILURE, { savedCartItems }) // 请求返回失败则设置提交失败状态
)
}
}

Vue组件中点击购买执行当前module的dispatch方法,传入type值为 ‘checkout’,payload值为 ‘products’,在源码中dispatch方法在所有注册过的actions中查找’checkout’的对应执行数组,取出循环执行。执行的是被封装过的被命名为wrappedActionHandler的方法,真正传入的checkout的执行函数在wrappedActionHandler这个方法中被执行,源码如下(注:前面贴过,这里再看一次):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function wrappedActionHandler (payload, cb) {
let res = handler({
dispatch: local.dispatch,
commit: local.commit,
getters: local.getters,
state: local.state,
rootGetters: store.getters,
rootState: store.state
}, payload, cb)
if (!isPromise(res)) {
res = Promise.resolve(res)
}
if (store._devtoolHook) {
return res.catch(err => {
store._devtoolHook.emit('vuex:error', err)
throw err
})
} else {
return res
}
}

handler在这里就是传入的checkout函数,其执行需要的commit以及state就是在这里被传入,payload也传入了,在实例中对应接收的参数名为products。commit的执行也是同理的,实例中checkout还进行了一次commit操作,提交一次type值为types.CHECKOUT_REQUEST的修改,因为mutation名字是唯一的,这里进行了常量形式的调用,防止命名重复,执行跟源码分析中一致,调用 function wrappedMutationHandler (payload) { handler(local.state, payload) } 封装函数来实际调用配置的mutation方法。

看到完源码分析和上面的小实例,应该能理解dispatch action和commit mutation的工作原理了。接着看源码,看看getters是如何实现state实时访问的。

4.6 store._vm组件设置

执行完各module的install后,执行resetStoreVM方法,进行store组件的初始化。

1
2
3
// initialize the store vm, which is responsible for the reactivity
// (also registers _wrappedGetters as computed properties)
resetStoreVM(this, state)

综合前面的分析可以了解到,Vuex其实构建的就是一个名为store的vm组件,所有配置的state、actions、mutations以及getters都是其组件的属性,所有的操作都是对这个vm组件进行的。

一起看下resetStoreVM方法的内部实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
function resetStoreVM (store, state) {
const oldVm = store._vm // 缓存前vm组件

// bind store public getters
store.getters = {}
const wrappedGetters = store._wrappedGetters
const computed = {}

// 循环所有处理过的getters,并新建computed对象进行存储,通过Object.defineProperty方法为getters对象建立属性,使得我们通过this.$store.getters.xxxgetter能够访问到该getters
forEachValue(wrappedGetters, (fn, key) => {
// use computed to leverage its lazy-caching mechanism
computed[key] = () => fn(store)
Object.defineProperty(store.getters, key, {
get: () => store._vm[key],
enumerable: true // for local getters
})
})

// use a Vue instance to store the state tree
// suppress warnings just in case the user has added
// some funky global mixins
const silent = Vue.config.silent

// 暂时将Vue设为静默模式,避免报出用户加载的某些插件触发的警告
Vue.config.silent = true
// 设置新的storeVm,将当前初始化的state以及getters作为computed属性(刚刚遍历生成的)
store._vm = new Vue({
data: { state },
computed
})

// 恢复Vue的模式
Vue.config.silent = silent

// enable strict mode for new vm
if (store.strict) {
// 该方法对state执行$watch以禁止从mutation外部修改state
enableStrictMode(store)
}

// 若不是初始化过程执行的该方法,将旧的组件state设置为null,强制更新所有监听者(watchers),待更新生效,DOM更新完成后,执行vm组件的destroy方法进行销毁,减少内存的占用
if (oldVm) {
// dispatch changes in all subscribed watchers
// to force getter re-evaluation.
store._withCommit(() => {
oldVm.state = null
})
Vue.nextTick(() => oldVm.$destroy())
}
}

resetStoreVm方法创建了当前store实例的_vm组件,至此store就创建完毕了。上面代码涉及到了严格模式的判断,看一下严格模式如何实现的。

1
2
3
4
5
function enableStrictMode (store) {
store._vm.$watch('state', () => {
assert(store._committing, `Do not mutate vuex store state outside mutation handlers.`)
}, { deep: true, sync: true })
}

很简单的应用,监视state的变化,如果没有通过 this._withCommit() 方法进行state修改,则报错。

4.7 plugin注入

最后执行plugin的植入。

1
plugins.concat(devtoolPlugin).forEach(plugin => plugin(this))

devtoolPlugin提供的功能有3个:

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 触发Vuex组件初始化的hook
devtoolHook.emit('vuex:init', store)

// 2. 提供“时空穿梭”功能,即state操作的前进和倒退
devtoolHook.on('vuex:travel-to-state', targetState => {
store.replaceState(targetState)
})

// 3. mutation被执行时,触发hook,并提供被触发的mutation函数和当前的state状态
store.subscribe((mutation, state) => {
devtoolHook.emit('vuex:mutation', mutation, state)
})

源码分析到这里,Vuex框架的实现原理基本都已经分析完毕。文章一开始提到的几个问题也得以解决,下面来总结一下。

五、总结

最后我们回过来看文章开始提出的5个问题。

1.  问:使用Vuex只需执行 Vue.use(Vuex),并在Vue的配置中传入一个store对象的示例,store是如何实现注入的?

答:Vue.use(Vuex) 方法执行的是install方法,它实现了Vue实例对象的init方法封装和注入,使传入的store对象被设置到Vue上下文环境的$store中。因此在Vue Component任意地方都能够通过this.$store访问到该store。

2.  问:state内部支持模块配置和模块嵌套,如何实现的?

答:在store构造方法中有makeLocalContext方法,所有module都会有一个local context,根据配置时的path进行匹配。所以执行如dispatch('submitOrder', payload)这类action时,默认的拿到都是module的local state,如果要访问最外层或者是其他module的state,只能从rootState按照path路径逐步进行访问。

3.  问:在执行dispatch触发action(commit同理)的时候,只需传入(type, payload),action执行函数中第一个参数store从哪里获取的?

答:store初始化时,所有配置的action和mutation以及getters均被封装过。在执行如dispatch('submitOrder', payload)的时候,actions中type为submitOrder的所有处理方法都是被封装后的,其第一个参数为当前的store对象,所以能够获取到 { dispatch, commit, state, rootState } 等数据。

4.  问:Vuex如何区分state是外部直接修改,还是通过mutation方法修改的?

答:Vuex中修改state的唯一渠道就是执行 commit('xx', payload) 方法,其底层通过执行 this._withCommit(fn) 设置_committing标志变量为true,然后才能修改state,修改完毕还需要还原_committing变量。外部修改虽然能够直接修改state,但是并没有修改_committing标志位,所以只要watch一下state,state change时判断是否_committing值为true,即可判断修改的合法性。

5.  问:调试时的”时空穿梭”功能是如何实现的?

答:devtoolPlugin中提供了此功能。因为dev模式下所有的state change都会被记录下来,’时空穿梭’ 功能其实就是将当前的state替换为记录中某个时刻的state状态,利用 store.replaceState(targetState) 方法将执行this._vm.state = state 实现。

源码中还有一些工具函数类似registerModule、unregisterModule、hotUpdate、watch以及subscribe等,如有兴趣可以打开源码看看,这里不再细述。

Vue双向绑定原理解析

发表于 2017-08-20 | 分类于 架构相关

前言

MVVM时代到来,各个前端框架大放光彩,其中Vue以其自身学习成本低,渲染效率高,开发文档齐全,issue修复及时等特点获得了很不错的口碑,成为了业内前端项目的主要开发框架。我们的项目目前也是主要以Vue来进行开发的,双向绑定是整个框架底层实现的核心,今天就来对双向绑定这个概念进行刨根问底,了解目前常见的绑定方法,以及对Vue双向绑定原理实现进行解析。

1. 常见数据绑定方法

了解Vue具体实现之前,先介绍一下当前常见的数据绑定方法。

  1. 订阅者–发布者模式:订阅者通过订阅消息的方式,将自身添加到发布者的订阅列表当中(subs),并且保存自身的发布者列表(pubs),来实现数据与视图的绑定监听(类似自定义事件的实现,事件触发后,逐个执行subs中的通知方法);
  2. 脏检查:设置监听数据的watcher方法,数据改变后,进入digest阶段(特殊事件自动触发或者用户手动触发),循环执行所有watcher来更新数据,直到数据全部更新完毕;
  3. 数据劫持:通过Object.defineProperty方法进行实现,将指定数据进行setter和getter方法的重定义,为该数据设置值的时候,会调用setter方法,在setter方法中调用其绑定的回调方法。

脏检查会随着项目的增多、数据复杂度增加而变得效率低下,因为某个视图的更新,一次要执行完所有的watcher进行数据更新,所耗费的时间是大量且不易优化的。单独使用订阅者–发布者模式或是数据劫持都会让开发者在开发当中书写大量的代码,虽然灵活性更高,但学习成本和使用体验上都会增加一定难度。

Vue结合了订阅者–发布者模式和数据劫持的方式来实现其数据的双向绑定,并提供规范的开发方式,各类便订阅捷的API,为开发者的使用提供了不少的便利,下面来具体分析其实现。

2. Vue双向绑定分析

2.1 整体流程分析

首先看整体流程图来理解一下其中的相关概念。

vue-mvvm

图中包含了3个大的部分,分别是Observer、Compiler以及Watcher。下面详细介绍一下其具体扮演的角色:

  1. Observer:承担发布者的角色,同时也完成了数据劫持的功能。利用Object.defineProperty方法,劫持我们传入的data数据对象,为其设置Vue自定义的setter和getter方法。调用setter方法时,先更新数据,然后通过调度中心调度订阅者的处理函数;调用getter方法时,会先调度订阅者的登记方法,将其登记到自身专属的调度中心中,然后返回数据;
  2. Watcher:承担订阅者的角色,参数接受订阅目标以及目标更新的回调通知函数。在获取数据时,会将全局的唯一指针指向自己,以供发布者收集自身的信息以及进行调度中心的登记等其他操作,同时也会记录下自己所订阅的那些发布者的列表数据以进行排重;
  3. Compiler:提供Html标签、数据以及指令模板编译功能。将组件数据拼凑,读取data对象数据,整合成完整的虚拟DOM树以供后续处理。

分析可知Observer主要是Model数据模块的处理,Compiler主要是View层的编译处理,Watcher则是连接Compiler和Observer的核心。图中还可以看到Dep以及Updater这两个概念,Dep是连接Observer和Watcher的核心,可以叫做调度中心,存放的是某数据对应的订阅者列表数据,用于通知变化。Updater则是虚拟DOM差异比较,DOM更新的模块。

结合以上分析以及图中所示,整体流程为:创建Vue组件,劫持Data数据封装setter和getter方法,Compiler编译模板数据并创建编译Watcher,设置回调函数为更新视图方法,进入初始化视图方法,编译虚拟DOM的时候,由于通过Data数据的getter对象获取了数据,编译Watcher实例被添加到了对应数据的Dep数组当中,然后完成实际DOM的修改,显示页面。等到Data数据被修改的时候,setter方法通过Dep调度中心通知具体的Watcher更新,编译Watcher收到了这个更新,就会调用更新视图方法的回调函数,从而利用新的Data数据重新编译虚拟DOM,通过Updater进行页面的实际更新,完成Model到View层的绑定。

2.2 模块实现分析

上面分别分析了各个模块的作用以及整体的流程,接下来分模块进行分析,看看其具体的代码实现。由于Observer模块的功能实现基于Object.defineProperty方法,那么首先来简单了解一下这个方法。

2.2.1 Object.defineProperty

官方的说明非常详细,还有很多例子进行介绍,这里主要引用基础概念作为了解,详细地址点击这里进行查看。

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
调用方式为:

1
2
3
4
5
6
7
8
9
> /**
> * 对象属性定义或修改方法.
> * @param {Object} obj - 需要被操作的目标对象
> * @param {String} prop - 目标对象需要定义或修改的属性的名称。
> * @param {Object} descriptor - 将被定义或修改的属性的描述符。
> */
>
> Object.defineProperty(obj, prop, descriptor)
>

对象里目前存在的属性描述符有两种主要形式(descriptor):数据描述符和存取描述符。数据描述符是一个拥有可写或不可写值的属性。存取描述符是由一对 getter-setter 函数功能来描述的属性。描述符必须是两种形式之一;不能同时是两者。

数据描述符和存取描述符均具有以下可选键值:

configurable
当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。

enumerable
当且仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false。

数据描述符同时具有以下可选键值:

value
该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。

writable
当且仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变。默认为 false。

存取描述符同时具有以下可选键值:

get
一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined。

set
一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为 undefined。

从上述说明可以了解到Object.defineProperty方法是一个用于给对象定义属性描述符的方法,Vue借此来实现了数据劫持的功能。

2.2.2 Observer发布者

下面直接从代码的角度来分析Observer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that has this object as root $data

constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
const augment = hasProto
? protoAugment
: copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)
} else {
this.walk(value)
}
}

...

Vue在初始化获取到Data数据时,就会调用Observer构造方法构建实例,判断目标对象类型,然后调用原型上的walk方法进行数据处理。

1
2
3
4
5
6
7
8
9
10
...

walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}

...

walk方法获取目标对象自身所有可遍历的属性,对其执行defineReactive方法来进行劫持的最终实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
  ...

export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep() // 创建当前属性的调度中心

const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}

// cater for pre-defined getter/setters
const getter = property && property.get
const setter = property && property.set

let childOb = !shallow && observe(val) // 属性值为对象则也创建劫持,且保留引用方便后面依赖传递
Object.defineProperty(obj, key, { // 执行数据劫持
enumerable: true,
configurable: true,
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val // 执行getter本来的功能,返回当前属性值
if (Dep.target) { // 若当前有Watcher获取该属性的值,则通知调度中心进行处理
dep.depend()
if (childOb) { // 属性值若为对象则也通知当前的属性值的调度中心处理
childOb.dep.depend()
}
if (Array.isArray(value)) { // 特殊处理属性值为Array类型
dependArray(value)
}
}
return value // 执行getter源功能,返回属性值
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val // 保留当前value副本
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) { // 避免不必要的重复写入
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (setter) {
setter.call(obj, newVal) // 调用setter方法设置新的值
} else {
val = newVal
}
childOb = !shallow && observe(newVal) // 更新当前属性值对象的引用
dep.notify() // 通知调度中心当前属性值更新,从而通知所有的订阅者
}
})
}

...

数据处理完成后,获取和设置Data对象的数据时就会调用Vue定义的通知调度中心的方法了,下面来看下调度中心的代码,了解dep.depend方法和dep.notify方法做了哪些工作。

2.2.3 Dep调度中心

每个Dep调度中心都有一个ID属性,由0自增以进行区分在watcher当中会被用来排重,还有subs属性数组,用来存储需要被通知的那些订阅者(watcher),并且提供了一系列的方法来提供给发布者和订阅者使用以作为连接枢纽,下面看下Dep类的实现代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
let uid = 0

export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;

constructor () {
this.id = uid++ // id属性区分
this.subs = [] // 订阅者列表
}

addSub (sub: Watcher) {
this.subs.push(sub) // 添加参数中的订阅者实例到当前发布者的调度中心订阅者列表中
}

removeSub (sub: Watcher) {
remove(this.subs, sub) // 从调度中心列表中删除目标订阅者
}

depend () {
if (Dep.target) {
Dep.target.addDep(this) // 调用当前订阅者的addDep方法,将当前发布者传入
}
}

notify () {
// stabilize the subscriber list first
const subs = this.subs.slice() // 复制一份当前的订阅列表,以供通知操作,防止通知的时候subs被修改而漏掉通知的bug
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 逐个调用订阅者的update方法进行通知
}
}
}

在调度中心的实现当中,识别当前订阅者是基于Dep.target这个类属性来实现的,充当全局唯一的一个订阅者对象变量指针,看下具体的实现方法。

1
2
3
4
5
6
7
8
9
10
11
Dep.target = null // 
const targetStack = []

export function pushTarget (_target: Watcher) {
if (Dep.target) targetStack.push(Dep.target)
Dep.target = _target
}

export function popTarget () {
Dep.target = targetStack.pop()
}

pushTarget功能是存储当前的target到内部属性targetStack数组当中,然后将Dep.target类属性更新以供当前的调度中心操作;操作完毕后调用popTarget方法从targetStack数组当中还原之前的Dep.target值。以上两个方法都属于操作target变量以供调度中心使用,所以应该是订阅者实例watcher来使用的,下面进行Watcher的实现分析。

2.2.4 Watcher订阅者

Watcher作为订阅者,同时作为$watcher(API)的底层实现,主要提供了属性的计算更新功能以及和调度中心的交互功能(用于接收更新通知)。wather示例在初始化获取监控属性值的时候会调用其get方法,在该方法中启动了与调度中心的连接,先看一下简单的绑定图。

vue-watcher-bind&update

接下来看下watcher的实现代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
...

/**
* Evaluate the getter, and re-collect dependencies.
*/
get () {
pushTarget(this) // 调用调度中心Dep提供的方法,将当前Dep.target值指向this-watcher示例
let value
const vm = this.vm
try {
value = this.getter.call(vm, vm) // 调用getter方法获取值
} catch (e) {
if (this.user) {
handleError(e, vm, `getter for watcher "${this.expression}"`)
} else {
throw e
}
} finally {
// "touch" every property so they are all tracked as
// dependencies for deep watching
if (this.deep) {
traverse(value) // 如果是深度watch,则tranverse该值,若为对象,则将该watcher加入到其子属性的调度中心deps数组中
}
popTarget() // 依赖处理完成后,将当前Dep.target释放,还原为上次的值
this.cleanupDeps() // 经过一次更新后,可能监控的Value会变化,对应更新当前watcher的deps依赖数组
}
return value // 处理完依赖后返回本次获取的值
}

...

get方法调用调度中心提供的pushTarget设置全局Dep.targe值为自身,然后调用getter方法,转到observer的getter方法中,由于Dep.target有值,调度中心能够执行dep.depend方法,然后调用当前watcher实例的addDep方法,addDep方法获取到调度中心后,保存到dep依赖列表中,然后调用dep.addSub方法使调度中心保留自身,以供通知,看下watcher的addDep实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
...

/**
* Add a dependency to this directive.
*/
addDep (dep: Dep) { //调度中心调用addDep方法的时候传入了自身作为dep参数
const id = dep.id
if (!this.newDepIds.has(id)) { // 新依赖列表过滤,避免重复添加
this.newDepIds.add(id) // 保留到新ID数组
this.newDeps.push(dep) // 保留到新对象数组
if (!this.depIds.has(id)) { // 当前依赖列表过滤,避免重复添加
dep.addSub(this) // 通知调度中心添加当前watcher,以供通知
}
}
}

...

上面从watcher的getter方法调用出发,到调度中心,再到watcher自身,最后回到调度中心的流程就是一次依赖添加的完整过程。get方法当中还实现了对value属性的深度观察,通过循环调用其子属性的值,利用上述getter到dep的流程巧妙实现,看下traverse方法的流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  ...

/**
* Recursively traverse an object to evoke all converted
* getters, so that every nested property inside the object
* is collected as a "deep" dependency.
*/
const seenObjects = new Set()
function traverse (val: any) {
seenObjects.clear()
_traverse(val, seenObjects)
}

function _traverse (val: any, seen: ISet) {
let i, keys
const isA = Array.isArray(val)
if ((!isA && !isObject(val)) || !Object.isExtensible(val)) {
return
}
if (val.__ob__) {
const depId = val.__ob__.dep.id
if (seen.has(depId)) {
return
}
seen.add(depId)
}
if (isA) {
i = val.length
while (i--) _traverse(val[i], seen)
} else {
keys = Object.keys(val)
i = keys.length
while (i--) _traverse(val[keys[i]], seen) // 调用val[keyi[i]],调用getter,通知dep来添加watcher的收听
}
}

依赖添加完成后,当目标属性值进行了变更后,调度中心调用update方法,来完成watcher实例值的更新,最终调用创建时传入的callback函数,并将新旧value传入。考虑到性能问题,update方法实现兼容了3种更新方案,update及其调用的run方法代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
...

/**
* Subscriber interface.
* Will be called when a dependency changes.
*/
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true // lazy模式,调用时再更新
} else if (this.sync) {
this.run() // 同步模式,立即更新
} else {
queueWatcher(this) // 默认为添加到收听者队列,统一批量更新
}
}

/**
* Scheduler job interface.
* Will be called by the scheduler.
*/
run () {
if (this.active) {
const value = this.get() // 更新watcher值
if (
value !== this.value ||
// Deep watchers and watchers on Object/Arrays should fire even
// when the value is the same, because the value may
// have mutated.
isObject(value) ||
this.deep
) { // 确认值更新后,再进行更新
// set new value
const oldValue = this.value
this.value = value
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue) // callback调用
} catch (e) {
handleError(e, this.vm, `callback for watcher "${this.expression}"`)
}
} else {
this.cb.call(this.vm, value, oldValue) // callback调用
}
}
}
}
...

代码流程看完之后,看一下绑定和更新的执行流程图来加深理解。

vue-watcher-bind&update

经过上面的分析,我们了解了Observer数据劫持,Watcher通知接收及其Object深度观察,以及连接两者的枢纽观察中心Dep等的实现。还了解了一个从收听者发起的完整的一次双向绑定的流程,最后来看看Compiler当中如何利用Watcher来实现视图和数据绑定的。

2.2.5 Compiler巧绑Model

Compiler主要实现了对template的编译功能,完成对各类directives的支持,由于本文主要讨论双向绑定的原理,所以不去分析Compiler的指令解析。上小节提到了Compiler利用Watcher来完成跟model的绑定,这里的实现是在lifecycle.js中进行巧妙实现的,方法代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
  ...

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

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

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

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

// 绑定实现语句,通过设置watcher的getter方法为updateComponent来实现
vm._watcher = new Watcher(vm, updateComponent, noop)
hydrating = false

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

...

在mountComponent方法中,通过绑定updateComponent到watcher的getter上实现绑定。执行原理是渲染watcher在初始化的时候会调用updateComponent方法(此时Dep.target指向当前渲染watcher),该方法中调用了vm._render()方法,会将编译完成的html模板与data中的数据结合渲染为虚拟DOM,在此步骤当中,读取data数据,其getter会将该watcher绑定到对应data数据的调度中心dep数组当中作为通知对象,然后继续完成后续的对比和实际DOM渲染工作,此时html渲染完成,compiler中的view也通过watcher绑定到了model当中。后续model更新,dep数组逐个执行watcher.update方法,然后updateComponent又被执行了,编译出新的虚拟DOM,然后对比,更新实际DOM,完成从model到view的自动更新。

2.3 实例结合

看完上述原理分析,你或许会非常的懵逼,那么我们现在用一个最简单的官方实例来分析,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- html代码片段 -->
<div id="app-6">
<p>{{ message }}</p>
<input v-model="message">
</div>

<!-- vuejs代码 -->
<script>
var app6 = new Vue({
el: '#app-6',
data: {
message: 'Hello Vue!'
}
})
</script>

html页面渲染如下图。

vue-example-1

以上实例实现的功能就是input框和p标签初始化显示Hello Vue!,用户修改input框内的值,p标签内容跟随着变化。了解了之前的代码和解析后,下面我们用底层实现的流程(仅双向绑定,不涉及其他模块)来分析这一功能。

首先Vue接收到option后,Observer分析data对象,调用walk方法遍历其属性key,分别对其执行defineReactive方法,进行数据劫持。这里数据简单,执行后的message有了如下的属性描述。

1
2
3
4
5
6
7
8
9
{
message: {
enumerable: true,
configurable: true,
setter: function reactiveSetter: {...}
getter: function reactiveGetter: {...},
value: 'Hello Vue!'
}
}

除此之外,在message的setter和getter当中还能够对dep的实例进行访问操作,所以生成了一个专属于message属性的dep调度中心。在message的setter当中会更新其value操作,并通知当前dep调度中心的deps依赖数组中的订阅者对象(watcher)。在message的getter当中能得到当前访问该message的watcher,并通知dep进行保存。以上是对data对象进行数据劫持的步骤。

接着Vue分析我们传入的el元素中的template代码 <p></p><input v-model="message">,进行编译,生成一个可执行函数,执行这个函数,就会对其中的变量message进行访问获取value值,拼装出完整的html代码,触发虚拟DOM比较以及实际DOM的更新操作。这个函数被封装在了updateComponent函数之内。以上是对传入的template进行编译的步骤,将其封装为可执行函数。

compiler和observer是通过watcher进行连接的。所以在mountComponent方法当中,创建了一个watcher,实例化的时候将updateComponent方法作为其getter传入,那么初始化该watcher的时候,getter方法被执行,上一步的编译生成html代码的渲染方法就会被执行,其中访问了message变量,那么message的getter就会捕捉到当前这个watcher,其专属调度中心deps数组就会有此watcher的引用。以上是compiler将编译流程作为watcher的getter方法,并利用该watcher将编译流程绑定到数据上的步骤。

执行完上述步骤,初始化以及渲染流程就结束了,来分简短步骤看一下:

  1. observer劫持data:{ message: 'Hello Vue!'},生成setter、getter及其专属dep调度中心;
  2. compiler获取template,编译其为可执行函数,包含了整个编译和虚拟DOM比较,实际DOM操作的流程,封装在updateComponent方法内部;
  3. 在compiler流程最后,会创建一个watcher对象,将本次代码的编译等流程方法updateComponent绑定到这个watcher对象中,通过执行updateComponent方法,watcher会被绑定到data对象对应访问的属性上(deps数组内),然后在updateComponent方法内完成本次代码的初始化渲染,我们就可以在浏览器中看到实际的渲染效果了。

在页面当中我们修改message的值,会发生如下步骤:

  1. message的setter被调用,值被更新,其调度中心dep循环更新deps数组,那么编译watcher的update方法会被调用;
  2. watcher方法调用get方法进行value的更新,get方法内部会调用getter方法,也就是我们传入的updateComponent方法,该方法将模板编译输出html,此时编译的时候就拿到了新的message的值,编译成新的虚拟DOM,然后进行比较,完成实际DOM的更新;
  3. 页面上的p标签内部message显示的值更新了。

看完了以上的实例分析,其实可以发现vue做的事情很简单:将代码的渲染流程委托给watcher,watcher将自己放在数据更新通知队列中,数据更新,watcher收到通知,执行代码渲染流程,完成更新。

3. 知识扩展–计算属性Computed

经过上一节的模块实现及其之间关系的分析,大家已经了解Vue双向绑定的原理。由于Vue提供的Computed(api)利用Watcher模块进行了实现,所以这里再扩展了解一下该api的实现,先看一下整体的结构图帮助后面的代码实现解读。

vue-computed

computed的创建其实就是一个watcher实例的创建,computed的key为watcher实例的key,value是一个function(用户自定义,其中读取其他data属性值,进行操作后返回),会作为watcher的getter方法被其调用,下面结合代码实现进行分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  ...

function initComputed (vm: Component, computed: Object) {
process.env.NODE_ENV !== 'production' && checkOptionType(vm, 'computed')
const watchers = vm._computedWatchers = Object.create(null)

for (const key in computed) {
const userDef = computed[key]
let getter = typeof userDef === 'function' ? userDef : userDef.get // 设置getter为用户传入的function
if (process.env.NODE_ENV !== 'production') {
if (getter === undefined) {
warn(
`No getter function has been defined for computed property "${key}".`,
vm
)
getter = noop
}
}
// create internal watcher for the computed property.
watchers[key] = new Watcher(vm, getter, noop, computedWatcherOptions) // 为每个computed属性创建一个对应的watcher

// component-defined computed properties are already defined on the
// component prototype. We only need to define computed properties defined
// at instantiation here.
if (!(key in vm)) {
defineComputed(vm, key, userDef) // 合法的key名称才能被创建
} else if (process.env.NODE_ENV !== 'production') {
if (key in vm.$data) {
warn(`The computed property "${key}" is already defined in data.`, vm)
} else if (vm.$options.props && key in vm.$options.props) {
warn(`The computed property "${key}" is already defined as a prop.`, vm)
}
}
}
}

...

上面的代码在初始化state时被调用,为所有的computed属性创建对应watcher并且在过滤操作后,调用defineComputed方法进行进一步处理,看下defineComputed方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  ...

export function defineComputed (target: any, key: string, userDef: Object | Function) {
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = createComputedGetter(key)
sharedPropertyDefinition.set = noop
} else {
sharedPropertyDefinition.get = userDef.get
? userDef.cache !== false
? createComputedGetter(key)
: userDef.get
: noop
sharedPropertyDefinition.set = userDef.set
? userDef.set
: noop
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}

...

defineComputed方法执行的是将computed属性设置到vm对象上以供访问的操作,其中的get方法是由createComputedGetter执行返回的函数,看下其中的具体操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  ...

function createComputedGetter (key) {
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key] // 获取computed属性对应的watcher
if (watcher) {
if (watcher.dirty) {
watcher.evaluate() // 默认为lazy操作,调用时执行底层getter操作,为watcher.value赋值,并将当前watcher存到对应dep队列当中
}
if (Dep.target) { // 若有其他watcher依赖本属性,则传递该watcher,将其存入当前computed-watcher的依赖dep当中,使其直接得更新
watcher.depend() // 通知所有observer保存其他watcher
}
return watcher.value // 返回当前computed的值
}
}
}

...

从以上代码可以了解到createComputedGetter执行后返回的computedGetter才是真正实现computed功能的函数,其执行了evaluate方法进行值的更新,还传递了当前取值的其他watcher到dep当中,以帮助其获取更新通知。至于更新流程,data更新后,dep循环调用所有watcher.update方法通知更新,在computed-watcher当中,因为为lazy模式,所以仅标记当前watcher为dirty,不做其他更新处理;在compiler-watcher当中,新建虚拟DOM获取值的时候,读取computed属性,从而访问到了computed-watcher,此时更新该watcher的value值,并返回最新的值,最后完成页面的更新。以上为computed(api)的实现原理和更新流程。

4. 总结

本文主要围绕Vue的MVVM整体结构模块、模块的实现细节对其双向绑定原理进行了分析,最后还加入了个人对计算属性computed实现的分析理解。旨在能够在使用Vue的时候能够更加清晰的了解其底层实现的思路,帮助分析代码的逻辑流程,排除bug等情况。如果发现文中的解析思路有误,或者其他错误,欢迎大家给我指出改正,一起交流。

Vue源码解读(四)

发表于 2017-07-25 | 分类于 架构相关

本篇介绍vue源码项目的大概文件结构,其实在其github上即有一个大概的介绍,https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md 英文水平可以的同学可以直接看。

题外话一句,vue这样的开源项目在贡献规则文档的说明上还是很完善的,其中包括有如何提bug,如何提PR,开发环境搭建以及目录结构说明等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
├── build --------------------------------- 构建相关的文件,一般情况下我们不需要动
├── dist ---------------------------------- 构建后文件的输出目录
├── examples ------------------------------ 存放一些使用Vue开发的应用案例
├── flow ---------------------------------- 类型声明,使用开源项目 [Flow](https://flowtype.org/)
├── package.json -------------------------- 不解释
├── test ---------------------------------- 包含所有测试文件
├── src ----------------------------------- 这个是我们最应该关注的目录,包含了源码
│ ├── entries --------------------------- 包含了不同的构建或包的入口文件
│ │ ├── web-runtime.js ---------------- 运行时构建的入口,输出 dist/vue.common.js 文件,不包含模板(template)到render函数的编译器,所以不支持 `template` 选项,我们使用vue默认导出的就是这个运行时的版本。大家使用的时候要注意
│ │ ├── web-runtime-with-compiler.js -- 独立构建版本的入口,输出 dist/vue.js,它包含模板(template)到render函数的编译器
│ │ ├── web-compiler.js --------------- vue-template-compiler 包的入口文件
│ │ ├── web-server-renderer.js -------- vue-server-renderer 包的入口文件
│ ├── compiler -------------------------- 编译器代码的存放目录,将 template 编译为 render 函数
│ │ ├── parser ------------------------ 存放将模板字符串转换成元素抽象语法树的代码
│ │ ├── codegen ----------------------- 存放从抽象语法树(AST)生成render函数的代码
│ │ ├── optimizer.js ------------------ 分析静态树,优化vdom渲染
│ ├── core ------------------------------ 存放通用的,平台无关的代码
│ │ ├── observer ---------------------- 反应系统,包含数据观测的核心代码
│ │ ├── vdom -------------------------- 包含虚拟DOM创建(creation)和打补丁(patching)的代码
│ │ ├── instance ---------------------- 包含Vue构造函数设计相关的代码
│ │ ├── global-api -------------------- 包含给Vue构造函数挂载全局方法(静态方法)或属性的代码
│ │ ├── components -------------------- 包含抽象出来的通用组件
│ ├── server ---------------------------- 包含服务端渲染(server-side rendering)的相关代码
│ ├── platforms ------------------------- 包含平台特有的相关代码
│ ├── sfc ------------------------------- 包含单文件组件(.vue文件)的解析逻辑,用于vue-template-compiler包
│ ├── shared ---------------------------- 包含整个代码库通用的代码

以上是个主要文件的大概介绍。其他一些次要文件如.babel这里有以下几点需要补充说明

###1.package.json
只说一点,scripts字段下的内容需要额外注意下,其中的dev,build,test,flow。

1
2
3
4
5
6
7
8
9
10
11
# watch and auto re-build dist/vue.js
$ npm run dev

# watch and auto re-run unit tests in Chrome
$ npm run dev:test

# build all dist files, including npm packages
$ npm run build

# run the full test suite, include linting / type checking
$ npm test

###2.flow
flow使用在上篇中已经提过,文件夹下主要是一些类的属性声明,作者已经按照类别分别放到了不同的文件中

1
2
3
4
5
6
7
8
├── flow
│ ├── compiler.js -------------------------- 与编译相关的如CompilerOptions,CompiledResult,ASTModifiers,ASTIfConditions等类型的声明
│ ├── component.js ------------------------- 即Component的类型声明。每个component的结构都如此,结构比较复杂,包括属性及函数等
│ ├── global-api.js ------------------------ 挂载到Vue上的全局函数和属性,如Vue.extend, Vue.set等
│ ├── modules.js --------------------------- 内部用到的一些module的接口说明,如he,source-map,lru-cache,vue-ssr-html-stream等
│ ├── options.js --------------------------- 各类option的属性说明,如InternalComponentOptions,ComponentOptions,PropOptions
│ ├── ssr.js ------------------------------- (server side rendering)服务端渲染用到的几个类的接口说明
│ └── vnode.js ----------------------------- 虚拟节点结构数据

###3.src目录
该目录下的是重点,而阅读时, src/core下的又是重中之重。
compiler目录主要负责编辑dom结构的渲染函数
entries是不同构建目标时不同的编辑入口
platform是不同平台下的额外处理,其他的不再赘言。

Vue源码解读(三)

发表于 2017-07-10 | 分类于 架构相关

本篇介绍vue源码项目的大概文件结构,其实在其github上即有一个大概的介绍,https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md 英文水平可以的同学可以直接看。

题外话一句,vue这样的开源项目在贡献规则文档的说明上还是很完善的,其中包括有如何提bug,如何提PR,开发环境搭建以及目录结构说明等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
├── build --------------------------------- 构建相关的文件,一般情况下我们不需要动
├── dist ---------------------------------- 构建后文件的输出目录
├── examples ------------------------------ 存放一些使用Vue开发的应用案例
├── flow ---------------------------------- 类型声明,使用开源项目 [Flow](https://flowtype.org/)
├── package.json -------------------------- 不解释
├── test ---------------------------------- 包含所有测试文件
├── src ----------------------------------- 这个是我们最应该关注的目录,包含了源码
│ ├── entries --------------------------- 包含了不同的构建或包的入口文件
│ │ ├── web-runtime.js ---------------- 运行时构建的入口,输出 dist/vue.common.js 文件,不包含模板(template)到render函数的编译器,所以不支持 `template` 选项,我们使用vue默认导出的就是这个运行时的版本。大家使用的时候要注意
│ │ ├── web-runtime-with-compiler.js -- 独立构建版本的入口,输出 dist/vue.js,它包含模板(template)到render函数的编译器
│ │ ├── web-compiler.js --------------- vue-template-compiler 包的入口文件
│ │ ├── web-server-renderer.js -------- vue-server-renderer 包的入口文件
│ ├── compiler -------------------------- 编译器代码的存放目录,将 template 编译为 render 函数
│ │ ├── parser ------------------------ 存放将模板字符串转换成元素抽象语法树的代码
│ │ ├── codegen ----------------------- 存放从抽象语法树(AST)生成render函数的代码
│ │ ├── optimizer.js ------------------ 分析静态树,优化vdom渲染
│ ├── core ------------------------------ 存放通用的,平台无关的代码
│ │ ├── observer ---------------------- 反应系统,包含数据观测的核心代码
│ │ ├── vdom -------------------------- 包含虚拟DOM创建(creation)和打补丁(patching)的代码
│ │ ├── instance ---------------------- 包含Vue构造函数设计相关的代码
│ │ ├── global-api -------------------- 包含给Vue构造函数挂载全局方法(静态方法)或属性的代码
│ │ ├── components -------------------- 包含抽象出来的通用组件
│ ├── server ---------------------------- 包含服务端渲染(server-side rendering)的相关代码
│ ├── platforms ------------------------- 包含平台特有的相关代码
│ ├── sfc ------------------------------- 包含单文件组件(.vue文件)的解析逻辑,用于vue-template-compiler包
│ ├── shared ---------------------------- 包含整个代码库通用的代码

以上是个主要文件的大概介绍。其他一些次要文件如.babel这里有以下几点需要补充说明

###1.package.json
只说一点,scripts字段下的内容需要额外注意下,其中的dev,build,test,flow。

1
2
3
4
5
6
7
8
9
10
11
# watch and auto re-build dist/vue.js
$ npm run dev

# watch and auto re-run unit tests in Chrome
$ npm run dev:test

# build all dist files, including npm packages
$ npm run build

# run the full test suite, include linting / type checking
$ npm test

###2.flow
flow使用在上篇中已经提过,文件夹下主要是一些类的属性声明,作者已经按照类别分别放到了不同的文件中

1
2
3
4
5
6
7
8
├── flow
│ ├── compiler.js -------------------------- 与编译相关的如CompilerOptions,CompiledResult,ASTModifiers,ASTIfConditions等类型的声明
│ ├── component.js ------------------------- 即Component的类型声明。每个component的结构都如此,结构比较复杂,包括属性及函数等
│ ├── global-api.js ------------------------ 挂载到Vue上的全局函数和属性,如Vue.extend, Vue.set等
│ ├── modules.js --------------------------- 内部用到的一些module的接口说明,如he,source-map,lru-cache,vue-ssr-html-stream等
│ ├── options.js --------------------------- 各类option的属性说明,如InternalComponentOptions,ComponentOptions,PropOptions
│ ├── ssr.js ------------------------------- (server side rendering)服务端渲染用到的几个类的接口说明
│ └── vnode.js ----------------------------- 虚拟节点结构数据

###3.src目录
该目录下的是重点,而阅读时, src/core下的又是重中之重。
compiler目录主要负责编辑dom结构的渲染函数
entries是不同构建目标时不同的编辑入口
platform是不同平台下的额外处理,其他的不再赘言。

Vue源码解读(二)

发表于 2017-07-02 | 分类于 架构相关

本篇介绍vue源码项目的大概文件结构,其实在其github上即有一个大概的介绍,https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md 英文水平可以的同学可以直接看。

题外话一句,vue这样的开源项目在贡献规则文档的说明上还是很完善的,其中包括有如何提bug,如何提PR,开发环境搭建以及目录结构说明等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
├── build --------------------------------- 构建相关的文件,一般情况下我们不需要动
├── dist ---------------------------------- 构建后文件的输出目录
├── examples ------------------------------ 存放一些使用Vue开发的应用案例
├── flow ---------------------------------- 类型声明,使用开源项目 [Flow](https://flowtype.org/)
├── package.json -------------------------- 不解释
├── test ---------------------------------- 包含所有测试文件
├── src ----------------------------------- 这个是我们最应该关注的目录,包含了源码
│ ├── entries --------------------------- 包含了不同的构建或包的入口文件
│ │ ├── web-runtime.js ---------------- 运行时构建的入口,输出 dist/vue.common.js 文件,不包含模板(template)到render函数的编译器,所以不支持 `template` 选项,我们使用vue默认导出的就是这个运行时的版本。大家使用的时候要注意
│ │ ├── web-runtime-with-compiler.js -- 独立构建版本的入口,输出 dist/vue.js,它包含模板(template)到render函数的编译器
│ │ ├── web-compiler.js --------------- vue-template-compiler 包的入口文件
│ │ ├── web-server-renderer.js -------- vue-server-renderer 包的入口文件
│ ├── compiler -------------------------- 编译器代码的存放目录,将 template 编译为 render 函数
│ │ ├── parser ------------------------ 存放将模板字符串转换成元素抽象语法树的代码
│ │ ├── codegen ----------------------- 存放从抽象语法树(AST)生成render函数的代码
│ │ ├── optimizer.js ------------------ 分析静态树,优化vdom渲染
│ ├── core ------------------------------ 存放通用的,平台无关的代码
│ │ ├── observer ---------------------- 反应系统,包含数据观测的核心代码
│ │ ├── vdom -------------------------- 包含虚拟DOM创建(creation)和打补丁(patching)的代码
│ │ ├── instance ---------------------- 包含Vue构造函数设计相关的代码
│ │ ├── global-api -------------------- 包含给Vue构造函数挂载全局方法(静态方法)或属性的代码
│ │ ├── components -------------------- 包含抽象出来的通用组件
│ ├── server ---------------------------- 包含服务端渲染(server-side rendering)的相关代码
│ ├── platforms ------------------------- 包含平台特有的相关代码
│ ├── sfc ------------------------------- 包含单文件组件(.vue文件)的解析逻辑,用于vue-template-compiler包
│ ├── shared ---------------------------- 包含整个代码库通用的代码

以上是个主要文件的大概介绍。其他一些次要文件如.babel这里有以下几点需要补充说明

###1.package.json
只说一点,scripts字段下的内容需要额外注意下,其中的dev,build,test,flow。

1
2
3
4
5
6
7
8
9
10
11
# watch and auto re-build dist/vue.js
$ npm run dev

# watch and auto re-run unit tests in Chrome
$ npm run dev:test

# build all dist files, including npm packages
$ npm run build

# run the full test suite, include linting / type checking
$ npm test

###2.flow
flow使用在上篇中已经提过,文件夹下主要是一些类的属性声明,作者已经按照类别分别放到了不同的文件中

1
2
3
4
5
6
7
8
├── flow
│ ├── compiler.js -------------------------- 与编译相关的如CompilerOptions,CompiledResult,ASTModifiers,ASTIfConditions等类型的声明
│ ├── component.js ------------------------- 即Component的类型声明。每个component的结构都如此,结构比较复杂,包括属性及函数等
│ ├── global-api.js ------------------------ 挂载到Vue上的全局函数和属性,如Vue.extend, Vue.set等
│ ├── modules.js --------------------------- 内部用到的一些module的接口说明,如he,source-map,lru-cache,vue-ssr-html-stream等
│ ├── options.js --------------------------- 各类option的属性说明,如InternalComponentOptions,ComponentOptions,PropOptions
│ ├── ssr.js ------------------------------- (server side rendering)服务端渲染用到的几个类的接口说明
│ └── vnode.js ----------------------------- 虚拟节点结构数据

###3.src目录
该目录下的是重点,而阅读时, src/core下的又是重中之重。
compiler目录主要负责编辑dom结构的渲染函数
entries是不同构建目标时不同的编辑入口
platform是不同平台下的额外处理,其他的不再赘言。

Vue源码解读(一)

发表于 2017-06-28 | 分类于 架构相关

前言

vue源码工程其实内容相当丰富,不仅包含了vue主体内容,其中工程的结构方法也都值得我们学习,所以这一系列内容打算对整个vue源码做一个解析。此处以2.2.0版本为例,进行分析。内容大概分为:

1.使用的主要插件或者工具简介(学习的前提)
2.文件目录结构介绍
3.代码执行流程介绍
4.observer, dep,watcher观察模式介绍
5.dom渲染介绍

##二、主要插件和工具

###1.npm
这个无需多言,项目中包含一个主要文件 package.json文件即证明其中要用到不少npm的包,查看其package.json文件可对项目用到的一些工具和包有个大概了解。(以2.2.0版本为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
{
"name": "vue",
"version": "2.2.0",
"description": "Reactive, component-oriented view layer for modern web interfaces.",
"main": "dist/vue.runtime.common.js",
"module": "dist/vue.runtime.esm.js",
"unpkg": "dist/vue.js",
"typings": "types/index.d.ts",
"files": [
"dist/vue.js",
"dist/vue.min.js",
"dist/vue.runtime.js",
"dist/vue.runtime.min.js",
"dist/vue.common.js",
"dist/vue.runtime.common.js",
"src",
"types/index.d.ts",
"types/options.d.ts",
"types/plugin.d.ts",
"types/vnode.d.ts",
"types/vue.d.ts"
],
"scripts": {
"dev": "TARGET=web-full-dev rollup -w -c build/config.js",
"dev:cjs": "TARGET=web-runtime-cjs rollup -w -c build/config.js",
"dev:test": "karma start build/karma.dev.config.js",
"dev:ssr": "TARGET=web-server-renderer rollup -w -c build/config.js",
"dev:compiler": "TARGET=web-compiler rollup -w -c build/config.js",
"dev:weex": "TARGET=weex-framework rollup -w -c build/config.js",
"dev:weex:compiler": "TARGET=weex-compiler rollup -w -c build/config.js",
"build": "node build/build.js",
"build:ssr": "npm run build -- vue.runtime.common.js,vue-server-renderer",
"build:weex": "npm run build -- weex-vue-framework,weex-template-compiler",
"test": "npm run lint && flow check && npm run test:types && npm run test:cover && npm run test:e2e -- --env phantomjs && npm run test:ssr",
"test:unit": "karma start build/karma.unit.config.js",
"test:cover": "karma start build/karma.cover.config.js",
"test:e2e": "npm run build -- vue.min.js && node test/e2e/runner.js",
"test:weex": "npm run build:weex && jasmine JASMINE_CONFIG_PATH=test/weex/jasmine.json",
"test:ssr": "npm run build:ssr && jasmine JASMINE_CONFIG_PATH=test/ssr/jasmine.json",
"test:sauce": "npm run sauce -- 0 && npm run sauce -- 1 && npm run sauce -- 2",
"test:types": "tsc -p ./types/test/tsconfig.json",
"lint": "eslint src build test",
"flow": "flow check",
"sauce": "SAUCE=true karma start build/karma.sauce.config.js",
"bench:ssr": "npm run build:ssr && NODE_ENV=production node benchmarks/ssr/renderToString.js && NODE_ENV=production VUE_ENV=server node benchmarks/ssr/renderToStream.js",
"release": "bash build/release.sh",
"release:weex": "bash build/release-weex.sh",
"install:hooks": "ln -fs ../../build/git-hooks/pre-commit .git/hooks/pre-commit"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vuejs/vue.git"
},
"keywords": [
"vue"
],
"author": "Evan You",
"license": "MIT",
"bugs": {
"url": "https://github.com/vuejs/vue/issues"
},
"homepage": "https://github.com/vuejs/vue#readme",
"devDependencies": {
"babel-core": "^6.9.0",
"babel-eslint": "^7.1.0",
"babel-helper-vue-jsx-merge-props": "^2.0.2",
"babel-loader": "^6.2.4",
"babel-plugin-istanbul": "^4.0.0",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.2.0",
"babel-preset-es2015": "^6.9.0",
"babel-preset-flow-vue": "^1.0.0",
"buble": "^0.15.2",
"chromedriver": "^2.21.2",
"codecov.io": "^0.1.6",
"cross-spawn": "^5.0.1",
"de-indent": "^1.0.2",
"es6-promise": "^4.0.5",
"eslint": "^3.10.1",
"eslint-config-vue": "^2.0.1",
"eslint-loader": "^1.3.0",
"eslint-plugin-flowtype": "^2.16.0",
"eslint-plugin-jasmine": "^2.1.0",
"eslint-plugin-vue": "^2.0.0",
"flow-bin": "^0.39.0",
"he": "^1.1.0",
"http-server": "^0.9.0",
"jasmine": "^2.5.2",
"jasmine-core": "^2.5.2",
"karma": "^1.1.0",
"karma-chrome-launcher": "^2.0.0",
"karma-coverage": "^1.0.0",
"karma-firefox-launcher": "^1.0.0",
"karma-jasmine": "^1.0.2",
"karma-mocha-reporter": "^2.0.4",
"karma-phantomjs-launcher": "^1.0.0",
"karma-safari-launcher": "^1.0.0",
"karma-sauce-launcher": "^1.0.0",
"karma-sourcemap-loader": "^0.3.0",
"karma-webpack": "^2.0.1",
"lodash": "^4.17.1",
"nightwatch": "^0.9.9",
"nightwatch-helpers": "^1.2.0",
"phantomjs-prebuilt": "^2.1.1",
"resolve": "^1.2.0",
"rollup": "^0.41.4",
"rollup-plugin-alias": "^1.2.0",
"rollup-plugin-babel": "^2.4.0",
"rollup-plugin-buble": "^0.15.0",
"rollup-plugin-flow-no-whitespace": "^1.0.0",
"rollup-plugin-replace": "^1.1.0",
"rollup-watch": "^3.2.2",
"selenium-server": "^2.53.1",
"typescript": "^2.1.6",
"uglify-js": "^2.6.2",
"vue-ssr-html-stream": "^2.1.0",
"vue-ssr-webpack-plugin": "^1.0.0",
"webpack": "^2.2.0",
"weex-js-runtime": "^0.17.0-alpha4",
"weex-vdom-tester": "^0.1.4"
}
}

对于npm不了解的同学请自行百度,资料很多,这里不再赘述。(http://www.cnblogs.com/tzyy/p/5193811.html 这里有个比较全面的package.json文件内容介绍)

###2.flow
官网:https://flow.org/en/, 中文地址:https://zhenyong.github.io/flowtype/
flow是一个静态类型检查工具,不同于typeScript 它更像是个小且轻便的工具。
另外一点需注意的是,它也有一个配置文件 .flowconfig 相关配置信息 https://zhenyong.github.io/flowtype/docs/advanced-configuration.html,可以配置诸如是否额外检测某些文件或者目录 [include], 忽略某些文件[ignore],包含指定声明[libs],其他配置[options]等
在vue中flowconfig配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[ignore]
.*/node_modules/.*
.*/test/.*
.*/build/.*
.*/examples/.*
.*/benchmarks/.*

[include]

[libs]
flow

[options]
unsafe.enable_getters_and_setters=true
module.name_mapper='^compiler/\(.*\)$' -> '<PROJECT_ROOT>/src/compiler/\1'
module.name_mapper='^core/\(.*\)$' -> '<PROJECT_ROOT>/src/core/\1'
module.name_mapper='^shared/\(.*\)$' -> '<PROJECT_ROOT>/src/shared/\1'
module.name_mapper='^web/\(.*\)$' -> '<PROJECT_ROOT>/src/platforms/web/\1'
module.name_mapper='^weex/\(.*\)$' -> '<PROJECT_ROOT>/src/platforms/weex/\1'
module.name_mapper='^server/\(.*\)$' -> '<PROJECT_ROOT>/src/server/\1'
module.name_mapper='^entries/\(.*\)$' -> '<PROJECT_ROOT>/src/entries/\1'
module.name_mapper='^sfc/\(.*\)$' -> '<PROJECT_ROOT>/src/sfc/\1'

其中注意一点 libs配置为flow目录结构,查看项目的flow文件夹即可看到,其中含有各种类或者接口的声明 如,compiler, component, vnode等,这几个主要的类在vue项目中使用频率很高,而且属性众多,用这样的配置方便查看类结构是否符合预期,或者作为工具api查看。
另外在查看文件的时候可以看到,若再文件开头标有 / @flow / 则表示该文件受flow检查约束,否则没有。

###3.rollup
rollup是一个npm包,主要作用为用es6模块方式打包文件。
介绍:https://www.npmjs.com/package/rollup
配置:https://github.com/rollup/rollup/wiki/Command-Line-Interface
查看上述package.json中的scripts 字段即可发现,作者所用打包工具正是rollup, dev各种命令即可看到。
另外也能发现其中频繁提到的一个目录,build文件夹,所有相关配置文件,包括dev运行,build构建,以及test运行等的配置文件 均在build目录下。
这里略微多提一下,官网中也提到存在几套不同版本的vue, 如下图所示:
rollup
对应vue生成的dist目录下的文件vue.common.js,vue.esm.js, vue.js等内容。在package.json的scripts中设定了TARGET的值,在build/config.js 文件中可以看到,target对应的builds的值,vue.js文件对应的入口文件为src/entries/web-runtime-with-compiler.js,那么除了这个之外还有其他的对应的入口文件如web-runtime.js,这个不同的入口在后续阅读代码的时候会看到还是有一些不同的地方的。

###4.TypeScript *
官网:http://www.typescriptlang.org/index.html
TypeScript(以下简称TS)是JavaScript的超集,由微软开发,它类似于coffescript,旨在提高写JS的效率,增加了一些特性如静态语法检查等,它的文件后缀为ts, ts文件可以编译为js文件。
在vue项目,主要是用TS写了一个小型的测试,文件主要都在types目录下。其中test/tsconfig.json是TS的配置文件,可以简单看下,这块内容相对独立。

###5.ESLint *
了解的人应该比较多,主要是用来做一些简单的语法检查和统一代码风格。
网址:https://github.com/eslint/eslint
vue项目中只有在lint命令下可以看到只检查了 src, build 和test 目录,用处主要是在npm run test 以及build/git-hooks里设置,即当你提交vue的代码时,会设定检查eslint,通常情况用不到。

##三、其他

阅读源码之前,有必要对Vue的用法有个大致的了解,最好有些使用经验。在了解了作者对外暴露的接口之后,再去阅读接口背后的逻辑和作者的思路才能事半功倍。否则直接源码,对组件声明过程中各类触发的事件,Vue上挂载的函数,组件内可调用的函数都是抓瞎状态,读起来更容易找不到方向。

Webpack入门

发表于 2016-11-28 | 分类于 架构相关

webpack基础使用

webpack作为当下热门的打包工具之一,得到了很广泛的使用。其诞生更多的是为了解决单页面应用资源管理的问题,其将所有资源都视为模块,并通过JS来统一管理。

基本概念

  • entry : 入口。页面的入口JS文件,同时也是webpack打包的起始点,webpack自entry文件开始解析其依赖,形成一棵依赖树,最终将所有资源打包输出
  • loader : 资源 - 模块 的转换器。loader 将前端各种资源转换为js可以直接处理的模块,实现资源的集中管理,如:css-loader 将css处理为js模块等
  • Plugins : 插件。插件可以切入到webpack的处理流中,截获数据并进行一定的修改,拓展webpack的功能,如:修改文件hash等

简单配置

webpack默认的配置文件为 ./webpack.config.js,可以通过 --config 参数指定配置文件位置。

webpack会默认查找当前目录下的 webpack.config.js 作为配置文件
$ webpack
指定配置文件
$ webpack –config ./config/webpack.js

配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
	const path = require('path');

module.exports = {
// 入口文件,在该文件开始查找依赖
entry: {
app : "./app/entry",
},
output: {
// 资源(打包后的文件)输出的文件夹
path: path.resolve(__dirname, "dist"),

// entry输出的格式,允许使用占位符
// name : entry 的 key 或 main
// hash : 一次构建整个的hash,所有文件的 [hash] 都一致
// chunkhash : 单个文件的hash,每个文件不一致
filename: "[name].[chunkhash].js",
}
}
```
执行 `webpack`,`./app/entry` 及其依赖模块会被全部打包到一起,并输出打包文件 `./dist/app.2376f6b4433ee1ebc62e.js` 。
终端执行`webpack`可能会提示找不到命令,可以尝试使用 `node_modules/webpack/bin/webpack.js`。

## 进阶配置
webpack通过JS来管理所有模块,你可以在 js 中 require('base.css') 来将CSS文件引入页面,但是webpack本身是不能识别css文件的,这时候就需要使用 loader。

* loader : 资源 -> 模块 的转换器。loader 将前端各种资源转换为js可以直接处理的模块,实现资源的集中管理,如:css-loader 将css处理为js模块等

每种资源对应一个转换的loader,这里我们引入css文件,所以使用 `css-loader`,将css文件转换为js模块,并嵌入到JS文件中。

同时,插件可以让我们可以很轻松的去拓展webpack的功能,使优化打包文件成为可能。
```js
const path = require('path');

module.exports = {
// 入口文件,在该文件开始查找依赖
entry: {
// 三方库chunk
jquery : 'jquery',
vue : 'vue',
// 应用entry
app : "./app/entry",
},

// 上下文,entry 和 loader 相对于此目录解析
context: __dirname,

module: {
// 映射文件和loader,匹配的文件会用对应的loader处理
rules: [
{
// 匹配模块名的正则表达式
test: /\.js$/,
use: [{
loader: 'css-loader',
}]
},
],
},

plugins: [
// 抽取CSS到单独文件
new ExtractTextPlugin({
filename: '[name].[contenthash].css',
}),
// 抽取第三方库
new webpack.optimize.CommonsChunkPlugin({
names : ['jquery', 'vue', 'manifest'],
filename: "[name].[chunkhash].js",
minChunks: Infinity,
}),
// 固定模块ID
new webpack.HashedModuleIdsPlugin()
],

output: {
// 资源(打包后的文件)输出的文件夹
path: path.resolve(__dirname, "dist"),

// entry输出的格式,允许使用占位符
// name : entry 的 key 或 main
// hash : 一次构建整个的hash,所有文件的hash都一致
// chunkhash : 单个文件的hash,每个文件不一致
filename: "[name].[chunkhash].js",
}
}

常用插件

webpack提供了很强大的代码打包能力,但其做的只是到了可以用的地步,很多地方还是过于粗糙,比如:CSS文件嵌入到JS文件中;构建一次大部分文件的hash都会改变等。这对于苛刻的前端开发人员显然是不能接受的,好在webpack提供了对外的接口 plugin,允许你控制webpack的打包流。

  • Plugins : 插件。插件可以切入到webpack的处理流中,截获数据并进行一定的修改,拓展webpack的功能,如:修改文件hash等
  1. extract-text-webpack-plugin

    • 痛点 : webpack统一用JS管理资源,即:所有的资源都会被转换为JS模块,放在同一个JS文件中,这会生成一个比较大的js文件,也不符合前端的资源隔离原则
    • 插件作用 : 将require的CSS文件抽离成单独的样式文件
    • 效果 : 实现资源类型的分离;CSS文件可以并行加载;CSS模块的导入不再占用JS执行时间

      示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      const path = require('path');
      // 引入第三方插件
      var ExtractTextPlugin = require('extract-text-webpack-plugin');
      module.exports = {
      module: {
      rules: [
      {
      test: /\.css$/,
      // ExtractTextPlugin封装进自己的loader
      use: ExtractTextPlugin.extract({
      fallback: "style-loader",
      use: "css-loader"
      })
      },
      ]
      },
      plugins: [
      // 抽离css
      new ExtractTextPlugin({
      filename: '[name].[chunkhash].css',
      }),
      ]
      }
    • contenthash
      webpack中有两种hash占位符:

      • hash : 整个构建过程中所有文件的hash值,是全局的,即如果使用该占位符,所有文件的[hash]都是一样的
      • chunkhash : 单个chunk的hash,每个文件的[chunkhash]都不一样,可以更好的利用缓存

        ExtractTextPlugin在使用 [chunkhash] 时,其实使用的是抽离了css的js文件的hash,css文件 和 js文件 的 [chunkhash] 是一样的。
        导致的问题就是:css文件修改时,因为js文件没有被修改,其 [chunkhash] 是不会改变的,可能使用旧的缓存;js文件修改时,虽然css文件没有修改,但是其 [chunkhash] 还是会跟着js文件改变,导致缓存失效。
        为了解决这个问题,ExtractTextPlugin 引入了第三种hash类型:

      • contenthash : css文件抽离后,单独计算该文件的hash,使js文件和css文件互不影响。

  2. CommonsChunkPlugin

    • 痛点 : webpack默认是将所有引入的包都添加到 bundle 中,例如:两个 entry 都引用了 jquery,这两个 entry 对应的 bundle 里面各有一份 jquery 源码,造成代码冗余。
    • 作用 : CommonsChunkPlugin 插件用于将 bundle 中公共的部分抽离出来形成单独文件,而不是每个 bundle 都引入一份源代码。
    • 效果 : 将公共库和运行代码抽离成公共模块,减少代码冗余。

      示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      module.exports = {
      entry: {
      // 三方库
      vendor: ["jquery"],
      index: './src/index.js',
      },
      plugins: [
      new webpack.optimize.CommonsChunkPlugin({
      // common chunk的名字
      names: ['vendor', 'manifest'],
      // common chunk的文件名
      filename : '[name].[chunkhash].js',
      // 仅在这些 chunk 中查找重复模块,不指定则是全部的chunk
      chunks : []
      // chunk 至少被引用的次数,可以为函数 ( module, count ) => {}
      minChunks : 4,
      // 公共模块大小的最小值,如果小于该值,不进行导出
      // B?
      // minSize: 1000,
      })
      ],
      }
    • vendor : 抽离第三方库,使用长效缓存

      webpack打包文件时,打包出的每个模块都会有一部分运行时代码(模块ID映射,每个模块的hash,require和import的polyfill),其中模块hash变动是比较频繁的,一个模块的改动会导致其hash变化,进而改变运行时文件,进而影响所有模块,缓存利用效率很低。

    • manifest : 抽离了运行时代码,减小冗余的同时,将模块的hash提取出来,模块的改动只会影响 改动的模块 和 manifest 文件

  3. HashedModuleIdsPlugin

    • 痛点 : webpack使用一个自增的ID来标识每个模块,但是内部的排序并不稳定。例如:某一次编译中,jquery被指定的模块ID为0,entry1 引用了该模块,模块ID - 0就被硬编码到entry1中,下次编译时,因为引入了新的库 loadash,导致 jquery 的模块ID变为了 1,硬编码在entry1中的模块ID则需要更新,导致entry1的hash改变,缓存失效。
    • 作用 : HashedModuleIdsPlugin使用模块的相对路径hash作为模块ID,比内部排序更加可靠。
    • 效果 : 固定模块ID,引入和删除库文件时,不会导致其他模块hash变化

      示例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      module.exports = {
      plugins: [
      new webpack.HashedModuleIdsPlugin({
      hashFunction: 'sha256',
      hashDigest: 'hex',
      hashDigestLength: 20
      })
      ],
      }

小结

插件 是否内置 优化效果
extract-text-webpack-plugin 否 抽离CSS,可以并发加载;减小JS体积
CommonsChunkPlugin 是 抽离公共模块,减少冗余;抽离运行时文件,减少文件变动导致的缓存失效
HashedModuleIdsPlugin 是 固定模块ID,减少文件变动导致的缓存失效

相关技术

webpack2内置了很多开箱即用的功能,如 tree shaking,code splitting等。

tree shaking

tree shaking的概念由 rollup 提出,主要作用是剔除js中无用的export,配合代码压缩工具,去除更多无用代码。

maths.js

1
2
3
4
5
6
7
// 未使用的export
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}

main.js

1
2
import {cube} from './maths.js';
console.log(cube(5)); // 125

打包后的代码中 square 的export已经被标记清除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* ... webpackBootstrap ... */
/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__["a"] = cube;
// 未使用的export
function square(x) {
return x * x;
}

function cube(x) {
return x * x * x;
}

/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__maths_js__ = __webpack_require__(0);

console.log(__webpack_require__.i(__WEBPACK_IMPORTED_MODULE_0__maths_js__["a" /* cube */])(5)); // 125

/***/ })

生产环境下使用 UglifyJS 进行压缩,因为 square 函数没有被使用,会被剔除掉。

1
2
3
4
/* ... */
function(e,t,n){"use strict";function r(e){return e*e*e}t.a=r}
/* ... */
function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0});var r=n(0);console.log(n.i(r.a)(5))}

code splitting

webpack默认的打包方式是将依赖的代码全部打包到一个文件中,无论是单页面应用还是多页面应用都会遇到 bundle 越来越大的问题,webpack提供了两个解决方法:公共模块抽取 和 动态加载 来分割代码,对应的解决方案分别为 CommonsChunkPlugin 和 require.ensure。

  • CommonsChunkPlugin : 见插件
  • require.ensure : todo

参考资料

  • 配置
  • import-ed变量只读
  • https://github.com/webpack/webpack/issues/2867
  • HashedModuleIdsPlugin

跨域的原理及解决方案

发表于 2016-04-28 | 分类于 基础总结

什么是跨域

之所以有跨域(Cross-Origin, 也译作跨源)这种操作方式,是为了解决浏览器同源策略。同源,即协议相同、域名相同及端口相同。同源策略最早被网景引入是为了保证用户信息的安全,防止恶意的网站窃取数据。最初非同源限制是cookie,现在包括dom和xhr及fetch请求都不能跨域访问。

不受同源策略限制的情况也有很多,如:

  1. <script src="..."></script>标签嵌入跨域脚本;
  2. <link rel="stylesheet" href="...">标签嵌入CSS;
  3. <img>嵌入图片;
  4. <video> 和 <audio>嵌入多媒体资源;
  5. <object>, <embed> 和 <applet>的插件;
  6. @font-face引入的字体;
  7. <frame> 和 <iframe>载入的任何资源.

通过这些“例外”情况,就给了程序员们可乘之机,涌现出了许多奇技淫巧,这类跨域的解决方案姑且称之为hack流。

与此同时,w3c听取了程序员们对于跨域请求的渴望,推出了cors标准,通过设置服务器以达到ajax的跨域请求,html5也适时推出了postMessage api以满足跨域通信。这种官方开门允许跨域的方法,我们姑且称之为正统流。

跨域访问的解决方案

如上文所言,从原理上,跨域的解决方案可以大概分为正统流和hack流两大类。下面具体分别来介绍各种解决方法的实现。
Classification

分类

Hack流

IMG Ping——利用图片的跨域特性

我们每天浏览很多的网页,有时候可能会发现某些图片(<img>)是来自其他网站的,其实你看到的就是一种跨域,由于图片不受“同源策略”限制,我们就可以利用图片进行跨域了。我们将图片的src属性指向请求的地址,通过监听load和error事件,就能知道响应什么时候接受了,响应的数据可以是任意内容,但通常是204响应(No content 没有响应体)。图像ping的例子如下:

1
2
3
4
5
6
7
8
var btn = document.querySelector("#start-ping"); 
btn.onclick = function(){
var img = new Image();
img.onload = img.onerror = function(obj){
document.querySelector("#result").innerHTML = "finished";
};
img.src = "http://localhost:3000/img?r="+Math.random();
};

服务器端代码:

1
2
3
router.get('/img', function(req, res, next) { 
res.send('success!');
});

然而在实际使用中会发现,无法获取相应文本。

优点:兼容性好;缺点:只支持Get请求且无法获取相应。

JSONP——利用script标签的跨域特性

JSONP是JSON with padding(填充式JSON)的简写,是应用JSON的一种方法,看起来和JSON差不多,只不过是被包含在函数调用中的JSON。例如:

1
callback({"key":"val"});

JSONP由两部分组成:回调函数和数据,回调函数是响应到来时应该在页面中调用的函数,而数据是传入回调函数中的JSON数据(服务器填充的)。下面就是一个典型的JSONP请求:

1
http://example.com/jsonp?callback=handleResponse

JSONP也是不受“同源策略”限制的,原因和图片ping是一样的,<script>标签也可以跨域,因此我们可以通过利用JONP来动态创建<script>,并将其src指向一个跨域的URL,就可以完成和跨域得服务器之间的通信了。下面就来看一个例子:

1
2
3
4
5
6
7
8
9
var btn2 = document.querySelector("#start-jsonp"); 
btn2.onclick = function(){
var script = document.createElement("script");
script.src = "http://localhost:3000/jsonp";
document.body.insertBefore(script, document.body.firstChild);
};
function pagefunc(num){
document.querySelector("#result2").innerHTML = "我从服务器获得了一个随机数:"+num;
}

服务器代码:

1
2
3
router.get('/jsonp', function(req, res, next) {
res.send('pagefunc(' + Math.random() + ')');
});

JSONP是非常简单易用的,与图像ping相比,优点就是能直接访问响应文本,能够在服务器与客户端建立双向通信。但是JSONP也是有缺点的:JSONP直接从其他域加载代码执行,如果其他域不安全,可能会在响应中夹带一些恶意代码。其次,要确定JSONP请求是否失败并不容易,HTML5为<script>增加了onerror方法,但是目前支持度还不是很好。

优点:简单易用,可以获取响应文本;缺点:只能get,不易获取错误。

iframe实现跨域通信

iframe的src可以指向不同域的地址,如果我们新建一个iframe元素将其指向所需跨域请求的地址是否就可以实现跨域通信了呢?然而事实是,iframe跨域的话将会取不到其dom元素。幸好前辈程序员们孜孜不倦的追求中,从iframe的一些特性中找到了空子可钻。

端口协议主域相同、子域不同

对于主域相同而子域不同的例子,可以通过设置document.domain的办法来解决。具体的做法是可以在http://www.a.com/a.html和http://script.a.com/b.html两个文件中分别加上document.domain = ‘a.com’;然后通过a.html文件中创建一个iframe,去控制iframe的contentDocument,这样两个js文件之间就可以“交互”了。当然这种办法只能解决主域相同而二级域名不同的情况,如果你异想天开的把script.a.com的domian设为alibaba.com的话,就会报错了。

www.a.com上的a.html:

1
2
3
4
5
6
7
8
9
10
document.domain = 'a.com';
var ifr = document.createElement('iframe');
ifr.src = 'http://script.a.com/b.html';
ifr.style.display = 'none';
document.body.appendChild(ifr);
ifr.onload = function(){
var doc = ifr.contentDocument || ifr.contentWindow.document;
// 在这里操纵b.html
alert(doc.getElementsByTagName("h1")[0].childNodes[0].nodeValue);
};

script.a.com上的b.html:

1
document.domain = 'a.com';

子域也不同

常用的有两种方法:利用location.hash改变页面不刷新的特性或者window.name在src改变的情况下不发生改变的特性进行跨域通信。

location.hash

假设域名a.com下的文件a.html要和b.com域名下的b.html传递信息。

  1. a.html首先创建自动创建一个隐藏的iframe,iframe的src指向b.com域名下的b.html页面;
  2. b.html响应请求后再将通过修改a.html的hash值来传递数据。由于两个页面不在同一个域下IE、Chrome不允许修改parent.location.hash的值,所以要借助于a.com域名下的一个代理iframe;
  3. 同时在a.html上加一个定时器(支持onhashchange事件的话可以监听该事件),隔一段时间来判断location.hash的值有没有变化,一旦有变化则获取获取hash值。

a.com/a.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function startRequest(){
var ifr = document.createElement('iframe');
ifr.style.display = 'none';
ifr.src = 'http://www.b.com/b.html#do';
document.body.appendChild(ifr);
}
startRequest();
function checkHash() {
try {
var data = location.hash ? location.hash.substring(1) : '';
if (console.log) {
console.log('data: '+data);
}
} catch(e) {};
}
setInterval(checkHash, 2000);

b.com/b.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//模拟一个简单的参数处理操作
switch(location.hash){
case '#do':
callBack();
break;
case '#did':
//do something……
break;
}

function callBack(){
try {
parent.location.hash = 'somedata';
} catch (e) {
// ie、chrome的安全机制无法修改parent.location.hash,
// 所以要利用一个中间的b.com域下的代理iframe
var ifrproxy = document.createElement('iframe');
ifrproxy.style.display = 'none';
ifrproxy.src = 'http://a.com/c.html#somedata'; // 注意该文件在"a.com"域下
document.body.appendChild(ifrproxy);
}
}

a.com/c.html:

1
parent.parent.location.hash = self.location.hash.substring(1);

优点:1.可以解决域名完全不同的跨域。2.可以实现双向通讯;

缺点:location.hash会直接暴露在URL里,并且在一些浏览器里会产生历史记录,数据安全性不高也影响用户体验。另外由于URL大小的限制,支持传递的数据量也不大。有些浏览器不支持hashchange事件,需要轮询来获知URL的变化。

window.name

window对象有个name属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面都是共享一个window.name的,每个页面对window.name都有读写的权限,window.name是持久存在一个窗口载入过的所有页面中的。window.name属性的神奇之处在于name 值在不同的页面(甚至不同域名)加载后依旧存在(如果没修改则值不会变化),并且可以支持非常长的 name 值(2MB)。

跨域解决方案似乎可以呼之欲出了,假设a.com域下的a.html页面请求远端服务器的数据,我们在该页面下新建一个iframe标签,该iframe的src属性指向b.com/b.com(利用iframe标签的跨域能力),b.com/b.html文件里设置好window.name的值(也就是该iframe的contentWindow的name值),然后在a.com/a.html里读取该iframe的window.name值,一切似乎水到渠成,代码如下:

a.com/a.html:

1
2
3
4
5
6
iframe = document.createElement('iframe'),
iframe.src = 'http://b.com/b.html';
document.body.appendChild(iframe);
iframe.onload = function() {
console.log(iframe.contentWindow.name)
};

b.com/b.html:

1
window.name = "{'key':'val'}";

然而实际使用的时候发现,会报错:Protocals, domains and ports must match. 所以还是跨域了……

为什么会这样,因为如最开始所说,如果页面与iframe的src不同源,则无法取该框架的信息。于是我们想到,既然window.name有不变性,那么我们只要载入跨域src且设置window.name信息之后换个src去指定,问题也许迎刃而解?

新建空白页面a.com/c.html,并且修改a.com/a.html:

1
2
3
4
5
6
7
iframe = document.createElement('iframe'),
iframe.src = 'b.com/b.html';
document.body.appendChild(iframe);
iframe.onload = function() {
iframe.src = 'http://a.com/c.html';
console.log(iframe.contentWindow.name)
};

实际使用过程中,确实可以取到window.name的值,然而iframe的load事件绑定了修改src的方法,于是load事件就被持续触发了,aka 整个iframe不断的刷新……继续修改a.com/a.html的代码以期完美:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
iframe = document.createElement('iframe');
iframe.style.display = 'none';
var state = 0;

iframe.onload = function() {
if(state === 1) {
var data = JSON.parse(iframe.contentWindow.name);
console.log(data);
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
} else if(state === 0) {
state = 1;
iframe.contentWindow.location = 'http://a.com/c.html';
}
};

iframe.src = 'http://b.com/b.html';
document.body.appendChild(iframe);

已经不复存在环境的方法——ie6的bug, window.navigator

IE6的bug,父页面和子页面都可以访问window.navigator这个对象,在navigator上添加属性或方法可以共享。因为现在没有IE6环境,此处不再赘述。

正统流

原本为保证用户安全而引入的同源策略,却限制了开发人员本应有的跨域需求。cors和postMessage适时推出,一个从服务端一个从前端提出了各自的跨域请求解决思路。

CORS——跨域资源共享

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。

有了CORS,没有跨域通信能力的XHR及Fetch终于也可以跨域了。从前端实现来看,和同源ajax请求并无差别。

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站有权限访问哪些资源。另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

接下来的内容将讨论相关场景,并剖析该机制所涉及的 HTTP 首部字段。

这里,我们使用三个场景来解释跨域资源共享机制的工作原理。这些例子都使用 XMLHttpRequest 对象。

简单请求

Simple Request

某些请求不会触发 CORS 预检请求。称之为“简单请求”,请注意,该术语并不属于 Fetch (其中定义了 CORS)规范。若请求满足所有下述条件,则该请求可视为“简单请求”:

  • 使用下列方法之一:
    1. GET
    2. HEAD
    3. POST
      1. Content-Type :
      2. //注:仅当POST方法的Content-Type值等于下列之一才算作简单请求
        1. text/plain
        2. multipart/form-data
        3. application/x-www-form-urlencoded
  • Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:
    1. Accept
    2. Accept-Language
    3. Content-Language
    4. Content-Type (需要注意额外的限制)
    5. DPR
    6. Downlink
    7. Save-Data
    8. Viewport-Width
    9. Width

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。

下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。

1
2
3
4
5
6
GET /cors HTTP/1.1
Origin: http://a.com
Host: b.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

1
2
3
4
Access-Control-Allow-Origin: http://a.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与CORS请求相关的字段,都以 Access-Control- 开头:

字段名 是否必须 类型 含义
Access-Control-Allow-Origin 是 请求时Origin字段的值/* 接受特定来源的请求,*为任意来源
Access-Control-Allow-Credentials 否 布尔值 是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。
Access-Control-Expose-Headers 否 - CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。

由于CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。

1
Access-Control-Allow-Credentials: true

另一方面,开发者必须在AJAX请求中打开withCredentials属性。

1
2
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。

但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,开发者可以在请求中显式关闭withCredentials。

需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传。

预检请求

Preview Request
与前述简单请求不同,“需预检的请求”要求必须首先使用 OPTIONS 方法发起一个预检请求到服务器,以获知服务器是否允许该实际请求。”预检请求“的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。

当请求满足下述任一条件时,即应首先发送预检请求:

  • 使用了下面任一 HTTP 方法:
    1. PUT
    2. DELETE
    3. CONNECT
    4. OPTIONS
    5. TRACE
    6. PATCH
  • 人为设置了对 CORS 安全的首部字段集合之外的其他首部字段。该集合为:
    1. Accept
    2. Accept-Language
    3. Content-Language
    4. Content-Type (but note the additional requirements below)
    5. DPR
    6. Downlink
    7. Save-Data
    8. Viewport-Width
    9. Width
  • Content-Type 的值不属于下列之一:
    1. application/x-www-form-urlencoded
    2. multipart/form-data
    3. text/plain

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

下面是一个预检请求的HTTP头信息示例:

1
2
3
4
5
6
7
8
OPTIONS /cors HTTP/1.1
Origin: http://a.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: b.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

除了Origin字段,”预检”请求的头信息包括两个特殊字段:

字段名 是否必须 含义
Access-Control-Request-Method 是 列出浏览器的CORS请求会用到哪些HTTP方法
Access-Control-Request-Headers 否 该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段

服务器收到”预检”请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应:

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://a.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

而如果服务器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。报错信息如下:

1
2
XMLHttpRequest cannot load http://b.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

CORS与JSONP的使用目的相同,但是比JSONP更强大。

优点:JSONP只支持GET请求,CORS支持所有类型的HTTP请求。

缺点:JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。

postMessage——跨文档通信

在hack流中我们了解到,iframe的src如果和父页面不同源,那么父页面将取不到该框架的信息。HTML5为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。这个API为window对象新增了一个window.postMessage方法,允许跨窗口通信,不论这两个窗口是否同源。Internet Explorer 8+, chrome,Firefox , Opera 和 Safari 都支持这个功能。但是Internet Explorer 8和9以及Firefox 6.0和更低版本仅支持字符串作为postMessage的消息。

postMessage方法的第一个参数是具体的信息内容,第二个参数是接收消息的窗口的源(origin),即”协议 + 域名 + 端口”。也可以设为*,表示不限制域名,向所有窗口发送。

父窗口和子窗口都可以通过message事件,监听对方的消息。message事件的事件对象event,提供以下三个属性:

  1. event.source: 发送消息的窗口;
  2. event.origin: 消息发向的网址;
  3. event.data: 消息内容.

一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var onmessage = function (event) { 
var data = event.data;//消息
var origin = event.origin;//消息来源地址
var source = event.source;//源Window对象
if(origin == "http://www.aaa.com"){
console.log(data);//hello world!
}
source.postMessage('Nice to see you!', '*');
};
if (typeof window.addEventListener != 'undefined') {
window.addEventListener('message', onmessage, false);
} else if (typeof window.attachEvent != 'undefined') {
//ie
window.attachEvent('onmessage', onmessage);
}

其他

时代眼泪——XDomainRequest

XDomainRequest是在IE8和IE9上的HTTP access control (CORS) 的实现,在IE10中被 包含CORS的XMLHttpRequest 取代了,该接口可以发送GET和POST请求。

语法

1
var xdr = new XDomainRequest();

返回XDomainRequest的实例,该实例可以被用来生成或管理请求。

XDomainRequest对象构成

构成 名称 含义
属性 timeout 获取或设置请求的过期时间
  responseText 以字符串形式获取响应体
方法 open() 根据指定的方法(GET或POST)和URL, 打开请求
  send() 发送请求, POST的数据会在该方法中被指定
  abort() 中止请求
事件处理程序 onprogress 当请求中发送方法和onload事件中有进展时的处理程序
  ontimeout 当请求超时时的事件处理程序
  onerror 当请求发生错误时的处理程序
  onload 当服务器端的响应被完整接收时的处理程序

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
if(window.XDomainRequest){
var xdr = new XDomainRequest();

xdr.open("get", "http://example.com/api/method");

xdr.onprogress = function () {
//Progress
};

xdr.ontimeout = function () {
//Timeout
};

xdr.onerror = function () {
//Error Occured
};

xdr.onload = function() {
//success(xdr.responseText);
}

setTimeout(function () {
xdr.send();
}, 0);
}

安全

XDomainRequest为了确保安全构建,采用了多种方法。

  • 安全协议源必须匹配请求的URL。(http到http,https到https)。如果不匹配,请求会报“拒绝访问”的错误。
  • 被请求的URL的服务器必须带有 设置为(“*”)或包含了请求方的Access-Control-Allow-Origin的头部。
开发专用——nginx反向代理

nginx反向代理的一个典型应用是均衡负载,然而其特性让我们在开发的时候可以方便的进行跨域设置。开发过程中一个典型的场景是,api已经上线,但是并没有设置cors,或者小作坊开发的时候,api服务在别人的电脑上也是开发状态,均不允许跨域调用。我们的应用上线之后发起的请求是同源的,然而在开发的时候是跨域的,apparently。这个时候也可以设置nginx反向代理实现跨域请求。

如果我们在a.com/a.html里ajax请求b.com/b?param=1, 必然会遇到跨域问题,如果我们打开nginx.conf配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
server{
listen 8000;
location / {
root html;
index index.html index.htm;
}
location /b {
rewrite ^/b/?$ /$1 break;
includeuwsgi_params;
proxy_pass http://b.com/b
}
}

总结

Extra

CSP——内容安全策略

JavaScript垃圾回收与内存泄漏

发表于 2016-01-28 | 分类于 基础总结

#前言
我们都知道JavaScript是自动进行内存回收的,在平常的开发中,我们也一般不会去关注内存问题。那么是否就意味着我们就不需要了解Js的内存回收了呢?
当然不是, 就像Java的内存回收一样,自动内存回收固然大大简化了开发者的工作,但算法策略并不是完美的,总会有各种特殊的情况导致内存无法正常回收。

#1 概念

##1.1 JavaScript数据类型
JavaScript的数据类型有:String、Number、Boolean、Array、Object、Null、Undefined。其中String、Number、Boolean、Null、Undefined属于基本类型,Array和Object属于引用类型。

##1.2 栈和堆
栈是操作系统使用的一种功能,它有大小限制,操作不灵活,它由系统自动分配和释放;而堆是编程语言提供的一种功能,是动态分配的内存,大小不定也不会由系统自动释放。其中:

  • 基本类型是存放在栈内存中的简单数据段,分为变量标识和值,均保存在栈内存,变量标识指向其对应的值,数据大小确定,内存空间大小可以分配。
  • 引用类型是存放在堆内存中的对象,变量实际保存的是一个指针,这个指针指向另一个位置。每个空间大小不一样,要根据情况开进行特定的分配。

当我们需要访问引用类型(如对象,数组)的值时,首先从栈中获得该对象的地址指针,然后再从堆内存中取得所需的数据。而我们提到的内存泄漏的情况只会发生在堆内存中(从概念可得知)。但是栈和堆都会发生内存溢出。

##1.3 内存泄漏与内存溢出
提到内存泄漏,我们平常还经常听到另一个名词内存溢出。其实这两个名词所代表的含义大不相同:

内存泄漏(Memory Leak)

内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。程序员认为合理的代码,但实际运行时,随着处理操作或请求,内存会不断上升直到超出程序设置的最大内存,导致程序奔溃。对于有垃圾回收机制的平台,到了接近内存溢出阶段,由于GC算法会不断尝试内存回收,系统的CPU会急剧提升。内存泄漏的问题,对后端服务或者游戏等长时间运行的程序影响比较大。对于存活时间较短的普通客户端危害较低,但是在SPA应用中内存泄漏的危害会被放大。

内存溢出(out of memory)

是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory。比方说栈,栈满时再做进栈必定产生空间溢出,叫上溢,栈空时再做退栈也产生空间溢出,称为下溢。就是分配的内存不足以放下数据项序列,称为内存溢出。内存溢出不一定是由内存泄漏引发的,也可能是程序本身申请的内存就不够。

以发生的方式来分类,内存泄漏可以分为4类:

  • 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
  • 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
  • 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。
  • 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存,最终引发内存溢出。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。

##1.4 垃圾回收
在计算机科学中,垃圾回收(英语:Garbage Collection,缩写为GC)是一种自动的内存管理机制。当一块动态内存不再需要时,就应该予以释放以让出内存,这种内存资源管理,称为垃圾回收(garbage collection)。垃圾回收器可以让程序员减轻许多负担,也减少程序员犯错的机会。垃圾回收最早起源于LISP语言。目前许多语言如Java、C#和JavaScript都支持垃圾回收器。
垃圾回收器有两个基本的原理:

  • 考虑某个对象在未来的程序运行中,将不会被访问。
  • 向这些对象要求归回内存。
    #2 JavaScript的内存回收机制
    Javascript在分配内存这一过程中,会根据不同的数据类型进行分配。像基本数据类型会分配在栈内存,引用数据类型分配在堆内存中。
    我们平时在使用定义变量,函数或者对象的时候,都在进行各种的内存分配,但是通常不需要写代码去回收,因为我们知道它是自动回收的。Js的内存回收算法有哪些呢?
    ##2.1 引用计数
    是指将资源的被引用次数保存起来,当被引用次数变为零时就将其释放的过程。使用引用计数技术可以实现自动资源管理的目的。同时引用计数还可以指使用引用计数技术回收未使用资源的垃圾回收算法。
    当创建一个对象的实例并在堆上申请内存时,对象的引用计数就为1,在其他对象中需要持有这个对象时,就需要把该对象的引用计数加1,需要释放一个对象时,就将该对象的引用计数减1,直至对象的引用计数为0,对象的内存会被立刻释放。
    1
    2
    3
    4
    //引用计数示例:
    var a = {}; //对象{}的引用计数为1
    b = a; //对象{}的引用计数为1+1
    a = null; //对象{}的引用计数为2-1,所以此时对象{}不会被回收;

循环引用导致内存不能正常被回收。

1
2
3
4
5
6
7
8
// 函数a执行完后,本来x, y对象都应该在垃圾回收阶段被回收, 可是由于存在循环引用,也不能被回收。
function a () {
var x = { };
var y = {};
x.a = y;
y.a = x;
}
a();

IE 6, 7 对DOM对象进行引用计数回收,这样简单的垃圾回收机制,非常容易出现循环引用问题导致内存不能被回收, 进行导致内存泄露等问题。

1
2
3
4
5
6
7
!function(){
//IE 6, 7中下列代码会导致btn不能被回收
var btn = document.getElementsByTagName('button');
btn.onclick = function(){
console.log(btn);
};
}();

##2.2 标记清除

目前主流浏览器均产用此内存回收机制。

标记清除的方式需要对程序的对象进行两次扫描,第一次从根(Root)开始扫描,被根引用了的对象标记为不是垃圾,不是垃圾的对象引用的对象同样标记为不是垃圾,以此递归。所有不是垃圾的对象的引用都扫描完了之后。就进行第二次扫描,第一次扫描中没有得到标记的对象就是垃圾了,对此进行回收。也就是它从之前判断”对象是否被需要”变成”对象是否可以获得”。这么理解,零引用的对象总是不可获得的,但是不可能获得的对象不一定零引用。

test03

比如定义一个变量,那么当它进入执行环境时,会被垃圾回收器标记为”进入环境”,当其离开环境比如函数执行完毕的时候,标记为”离开环境”。垃圾回收机器就会在这些”离开环境”的变量中挑选出来需要回收掉的变量用于释放内存。

##2.3 两种算法的优缺点
算法 | 优点 | 缺点
—|—|—
引用计数 | 1.尽快地回收不再被使用的对象
2.在回收过程中不会导致长时间的停顿
3.可以清晰地标明每一个对象的生存周期 | 1.频繁更新引用计数会降低运行效率
2.原始的引用计数无法解决循环引用问题
标记清除 | 1.没有更新引用的操作
2.不存在循环引用导致无法回收的问题 | 1.在内存即将耗尽时,会频繁的触发内存回收操作,导致性能急剧下降

#3 JavaScript中的内存泄漏
引起垃圾收集语言内存泄露的主要原因是不必要的引用。不必要的引用就是那些程序员知道这块内存已经没用了,但是出于某种原因这块内存依然存在于活跃的根节点发出的节点树中。在 Javascript 的环境中,不必要的引用是某些不再被使用的代码中的变量,这些变量指向了一块本来可以被释放的内存。一些人认为这是程序员的失误。

现代垃圾收集器使用不同的方式来改进标记清除算法,但是它们都有相同的本质:可以访问的内存块被标记为非垃圾而其余的就被视为垃圾。

那么什么情况下会产生不必要的引用呢?

##3.1 意外的全局变量
Javascript 语言的设计目标之一是开发一种类似于 Java 但是对初学者十分友好的语言。体现 JavaScript 宽容性的一点表现在它处理未声明变量的方式上:一个未声明变量的引用会在全局对象中创建一个新的变量。在浏览器的环境下,全局对象就是 window,也就是说:

1
2
3
4
5
6
7
8
9
//意外的全局变量:
function foo(arg) {
bar = {};
}

// 实际上是:
function foo(arg) {
window.bar = {};
}

注意上面提到的意外,如果 bar 是一个应该指向 foo 函数作用域内变量的引用,但是你忘记使用 var 来声明这个变量,这时一个全局变量就会被创建出来。在这个例子中,一个空的对象泄露并不会造成很大的危害,但这无疑是错误的。
下面的例子中其实也会意外的创建一个全局变量:

1
2
3
4
5
6
//另外一种偶然创建全局变量的方式:
function foo() {
this.bar = {};
}
//此时执行foo函数,this其实是指代window
foo();

为了防止这种错误的发生,可以在你的 JavaScript 文件开头添加 ‘use strict’; 语句。这个语句实际上开启了解释 JavaScript 代码的严格模式,这种模式可以避免创建意外的全局变量。

注意事项:

尽管我们在讨论那些隐蔽的全局变量,但是也有很多代码被明确的全局变量污染的情况。按照定义来讲,这些都是不会被回收的变量(除非设置 null 或者被重新赋值)。

特别需要注意的是那些被用来临时存储和处理一些大量的信息的全局变量。如果你必须使用全局变量来存储很多的数据,请确保在使用过后将它设置为 null 或者将它重新赋值。常见的和全局变量相关的引发内存消耗增长的原因就是缓存。缓存存储着可复用的数据。为了让这种做法更高效,必须为缓存的容量规定一个上界。由于缓存不能被及时回收的缘故,缓存无限制地增长会导致很高的内存消耗。

##3.2 没有及时清除的定时器和回调函数
在 JavaScript 中 setInterval 的使用十分常见。其他的库也经常会提供观察者和其他需要回调的功能。这些库中的绝大部分都会关注一点,就是当它们本身的实例被销毁之前销毁所有指向回调的引用。而目前主流浏览器都能很好的处理在被绑定的元素被移除后的回调函数。而setInterval则需要我们手动清除。
在 setInterval 这种情况下,一般情况下的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
//常见的定时器写法:
!function(){
var node = document.getElementById('search');
setInterval(function() {
console.log(node);
if(node) {
node.innerHTML = new Date();
}
}, 1000);
//移除node节点
node.remove();
}();

这个例子说明了定时器会发生什么:整个定时器是在一个立即执行函数中,当node节点从dom中移除后,整个定时器已经没有运行存在的必要了。然而,由于周期函数一直在运行,处理函数并不会被回收(只有周期函数停止运行之后才开始回收内存)。如果周期处理函数不能被回收,那么此函数的执行上下文相关的资源就不能被回收,例如node,进而导致root节点会一直保留在内存中(虽然dom中看不到了),甚至我们还可以看到node的innerHTML仍然在被修改,直到定时器被清除。

下面举一个观察者的例子,当它们不再被需要的时候(或者关联对象将要失效的时候)显式地将他们移除是十分重要的。在以前,尤其是对于某些浏览器(IE6、IE7)是一个至关重要的步骤,因为它们在管理dom对象的时候还是产用的引用计数的回收算法,不能很好地管理循环引用。现在,当观察者对象失效的时候便会被回收,即便 listener 没有被明确地移除,绝大多数的浏览器可以或者将会支持这个特性。尽管如此,在对象被销毁之前移除观察者依然是一个好的实践。示例如下:

1
2
3
4
5
6
7
8
9
10
!function(){
var element = document.getElementById('button');
function onClick(event) {
element.innerHtml = 'text';
}
element.addEventListener('click', onClick);
//在IE6、IE7浏览器上,移除node之前需要手动的removeEventListener,这样才能保证element被正常回收(其实就是循环引用)
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
}();

##3.3 DOM之外的引用

1
2
3
4
//DOM之外的引用:
var node = document.getElementById('search');
node.remove();
console.log(node);

此时该节点被全局变量node引用,导致其即使从dom中移除后,也不会从内存中回收。
还需要注意的是,就是对 DOM 树父节点的引用问题。
假设有两个父子关系的div,会发生什么呢?

1
2
3
4
5
<div id="root">
<div id="app">
<div id="son"></div>
</div>
</div>

1
2
3
4
var node = document.getElementById('son');
node.parentNode.remove(); //移除父节点
console.log(node.parentNode); //app可访问
console.log(node.parentNode.parentNode); //null

在上面的例子中,我们从DOM中移除了id为app的这个div,但是因为son被node这个全局变量引用着,导致app作为一个分离的DOM树整体(Detached DOM tree)并没有真正从内存中回收掉。所以当你想要保留 DOM 元素的引用时,要仔细的考虑清楚这一点。

##3.4 错误的使用闭包
JavaScript 开发中一个重要的内容就是闭包,而到底什么是闭包,到现在业内仍然是众说纷纭,有的人说这种格式叫做闭包,有的人说是可以获取父级作用域的函数。我个人赞成后者。有一种说法是:闭包会导致内存泄漏,那么这种说法对不对呢?首先看下面的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var replaceThing = function () {
//为了方便观察内存情况(正常情况下一般是不会有这么长的数组的),new一个有一亿项元素的数组,这样数组本身会占用很大的内存
var originalThing = new Array(100000000).join('*');
var outer = 'outer str';
console.log('new...');
return function () {
if (originalThing)
console.log(outer);
};
};
//得到闭包函数
var closureFn = replaceThing();
//执行
closureFn();

我们发现,内存占用明显增加了。我们把上面的代码稍加修改,重新执行下看下效果。

1
2
3
4
5
6
7
8
9
10
11
12
var replaceThing = function () {
//为了方便观察内存情况,new一个有一亿项元素的数组,这样数组本身会占用很大的内存
var originalThing = new Array(100000000).join('*');
var outer = 'outer str';
console.log('new...');
return function () {
if (originalThing)
console.log(outer);
};
};
//得到闭包函数并执行
replaceThing()();

测试发现,当代码执行完后,内存没有明显增加,也就是说闭包之所以会一起内存泄漏,是因为我们在使用的时候,错误的将闭包函数赋值给了一个全局变量,这样就会导致闭包及闭包作用域的变量不会被回收。
为了验证我们的想法,接下来我们创建多个闭包并执行看效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function test(){
var replaceThing = function () {
//为了方便观察内存情况,new一个有一亿项元素的数组,这样数组本身会占用很大的内存
var originalThing = new Array(100000000).join('*');
var outer = 'outer str';
console.log('new...');
return function () {
if (originalThing)
console.log(outer);
};
};
//得到闭包函数并执行
replaceThing()();
};
//每1秒执行一次,看内存变化
setInterval(test, 1000);

测试结果是内存并没有明显增加,一直稳定在某个区间,再次验证我们的想法,也就是说闭包本身并不会引起内存泄漏。
有一种特殊情况下有可能会以一种很微妙的方式产生内存泄漏,这实际上是 js 引擎的 bug , ECMA 规范不会造成这种内存泄露。这取决于JavaScript 的实现细节,这个 bug 在 Google Chrome / Node.js / Apple Safari / Mozilla Firefox 上都有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log('hi');
};
theThing = {
//为了方便观察内存情况,创建了一个很大的字符串,这样数组本身会占用很大的内存
longStr: new Array(100000000).join('*'),
someMethod: function () {
console.log('123');
}
};
};
//每1秒执行一次,看内存变化
setInterval(replaceThing, 1000);

以上代码执行,发生的内存泄漏问题,无法直观的看出来,但是我们可以通过Chrome开发者工具使用memory生成内存快照观察到。通过Chrome的内存快照功能可以明显看到内存在增加。等待数秒会发现Chrome报内存溢出了。并且当我们手动的清除掉定时器时,内存并没有被回收掉。
接下来,我试着用我的理解,解释上面的代码之所以会发生内存泄漏的原因。

test04

首先科普下:在执行函数的时候,如果遇到闭包,会创建闭包作用域内存空间,将该闭包所用到的局部变量添加进去,然后再遇到闭包,会在之前创建好的作用域空间添加此闭包会用到而前闭包没用到的变量。也就是说同一个父级作用域的多个闭包是共享一个闭包作用域的。

我们以执行了三次为例,第一次执行为xx1,以此类推,

  • 首先上面的代码中存在两个闭包,一个是unused,另一个是someMethod。
  • 再加上theThing处于replaceThing的运行时上下文环境中,theThing3要保证随时可以让replaceThing可以访问,所以theThing3不会被回收。
  • 进而someMethod3不会被回收。
  • 进而导致闭包作用域内的变量不会被回收。
  • 进而导致闭包作用域中闭包unused3的上下文参数theThing2没有被回收。
  • theThing2中的someMethod2又会导致unused2不会被回收。
  • 以此类推,所有 的theThing内存对象都不能被释放。

简而言之,就是发生内存泄漏的原因就在于因为共享闭包作用域的原因,多个闭包作用域形成了链式的依赖,导致所有的theThing内存对象都得不到释放。

为什么说错误的使用闭包会导致内存泄漏呢?

我们把上面的例子简单的修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
!function(){
var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing)
console.log('hi');
};
theThing = {
//为了方便观察内存情况,创建了一个很大的字符串,这样数组本身会占用很大的内存
longStr: new Array(100000000).join('*'),
someMethod: function () {
console.log('123');
}
};
};
//每1秒执行一次,看内存变化,我们将定时器的ID打印出来
console.log(setInterval(replaceThing, 1000));
}();

上面的代码后,我们把代码整个抱在一个立即执行函数中后再次执行,在我们手动终止定时器之前,内存是不断增加的,而当我们手动的停止定时器后,因为theThing不再是个全局变量,函数执行结束,就会被释放。
所以在所有主流浏览器都是共享闭包作用域的实现下,避免开发的过程中出现这种链式的作用域依赖才是关键。
从根本上看内存泄露的原因,就是引用没有释放导致的(这不是废话么),而不是闭包本身导致的,所以我的观点是错误的使用闭包才会导致内存泄漏。

##3.5 不恰当的全局缓存
如果我们在项目中不恰当的使用了全局缓存:主要是指只有增加缓存的操作而没有清除的操作,那么就会引起泄漏。由于缓存对象被全局变量引用着,那么在刷新页面前永远不会被清除掉。如果存储的缓存占用控件比较大的话,这种危害还会被放大。

#4 JavaScript内存排查
我们可通过以下方式察觉内存问题:

  • 页面的性能随着时间的延长越来越差。 这可能是内存泄漏的症状。 内存泄漏是指,页面中的错误导致页面随着时间的延长使用的内存越来越多。
  • 页面的性能一直很糟糕。 这可能是内存膨胀的症状。 内存膨胀是指,页面为达到最佳速度而使用的内存比本应使用的内存多。
  • 页面出现延迟或者经常暂停。 这可能是频繁垃圾回收的症状。 垃圾回收是指浏览器收回内存。 浏览器决定何时进行垃圾回收。 回收期间,所有脚本执行都将暂停。因此,如果浏览器经常进行垃圾回收,脚本执行就会被频繁暂停。

您可以使用 Chrome 任务管理器或者 Timeline(新版本已更名为Performance)内存记录发现频繁的垃圾回收。 戳这里了解更多使用细节。

  • 使用 Chrome 的任务管理器了解您的页面当前正在使用的内存量。内存值频繁上升和下降表示存在频繁的垃圾回收。
  • 使用 Timeline(Performance) 记录可视化一段时间内的内存使用。
  • 使用堆快照确定已分离的 DOM 树(Detached DOM tree)。
    #5 如何避免发生内存泄漏
  • 尽量避免使用全局变量,例如使用立即执行函数的形式
  • 使用“严格模式”开发,避免因为我们的疏忽导致意外产生全局变量
  • 对于一些占用内存较大的对象,在变量不在使用后,手动将其赋值为null,例如前面例子中的超大的数组
  • 尽量避免把外层引用赋予内部变量,例如上面例子中的theThing变量
12
Yourz

Yourz

17 日志
2 分类
25 标签
GitHub 微博 Instagram
© 2015 - 2018 Yourz
由 Hexo 强力驱动
主题 - NexT.Mist