没有足够的数据
李孤雏 发布了文章 · 2020-08-18
vue2里面的 watch api 大家应该都挺熟悉的了, vue2中vue实例里面有一个 $watch 方法 在sfc(sigle file component)里面有一个 watch 选项。他可以实现在一个属性变更的时候,去执行我们想要的行为。比如:
但是 vue3 除了 watch api, 还新增了一个 watchEffect 的 api, 我们来看看他的用法。
我们收集了一个 userID 的依赖,然后在 userID 改变的时候,就会执行watchEffect
的回调。
// 例子灵感来源于[文档](https://v3.vuejs.org/api/computed-watch-api.html#watcheffect)
import { watchEffect, ref } from 'vue'
setup () {
const userID = ref(0)
watchEffect(() => console.log(userID))
setTimeout(() => {
userID.value = 1
}, 1000)
/*
* LOG
* 0
* 1
*/
return {
userID
}
}
watchEffect
不需要指定监听的属性,他会自动的收集依赖, 只要我们回调中引用到了 响应式的属性, 那么当这些属性变更的时候,这个回调都会执行,而 watch
只能监听指定的属性而做出变更(v3开始可以同时指定多个)。watchEffect
是拿不到的。computed
同理),而后收集到的依赖发生变化,这个回调才会再次执行,而 watch 不需要,因为他一开始就指定了依赖。从他们的不同点可以看出,他们的优缺点。并且可以在业务需求面前做出正确的选择。
watchEffect
进阶watchEffect 会返回一个用于停止这个监听的函数,如法如下:
const stop = watchEffect(() => {
/* ... */
})
// later
stop()
例子来源于官方文档, 上面有链接。
如果 watchEffect
是在 setup
或者 生命周期里面注册的话,在组件取消挂载的时候会自动的停止掉。
什么是 side effect ,不可预知的接口请求就是一个 side effect,假设我们现在用一个用户ID去查询用户的详情信息,然后我们监听了这个用户ID, 当用户ID 改变的时候我们就会去发起一次请求,这很简单,用watch 就可以做到。 但是如果在请求数据的过程中,我们的用户ID发生了多次变化,那么我们就会发起多次请求,而最后一次返回的数据将会覆盖掉我们之前返回的所有用户详情。这不仅会导致资源浪费,还无法保证 watch 回调执行的顺序。而使用 watchEffect
我们就可以做到。
onInvalidate()
onInvalidate(fn)
传入的回调会在 watchEffect
重新运行或者 watchEffect
停止的时候执行
watchEffect(() => {
// 异步api调用,返回一个操作对象
const apiCall = someAsyncMethod(props.userID)
onInvalidate(() => {
// 取消异步api的调用。
apiCall.cancel()
})
})
借助 onInvalidate
我们就可以对上面所述的情况作出比较优雅的优化。
介绍结束。v3 值的期待。
查看原文vue2里面的 watch api 大家应该都挺熟悉的了, vue2中vue实例里面有一个 $watch 方法 在sfc(sigle file component)里面有一个 watch 选项。他可以实现在一个属性变更的时候,去执行我们想要的行为。比如:
赞 14 收藏 3 评论 1
李孤雏 发布了文章 · 2019-08-02
无规矩不成方圆
在技术领域上更是如此, 比如: 类名头字母大写, promiseA+ 规范, DOM 标准, es 标准, 都是规矩.
框架亦是如此, 比如Vue 就是尤大的一套规矩.
如果要打破规矩, 第一步要做的就是要了解规矩.
2.6版本
new Vue({})
之前)<details>
<summary>
Vue 构造函数
</summary>
// path: src/core/instance/index.js
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
</details>
其实他们都往 Vue 实例的原型链上添加了诸多的方法
initsrc/core/instance/init.js
Vue.prototype._init = functoin(options){...}
statesrc/core/instance/state.js
Vue.prototype.$data = {...}
Vue.prototype.$props = {...}
Vue.prototype.$set = function () {...}
Vue.prototype.$delete = function () {...}
Vue.prototype.$watch = functoin(expOrFn, cb, options){...}
eventssrc/core/instance/events.js
Vue.prototype.$on = functoin(event, fn){...}
Vue.prototype.$once = functoin(event, fn){...}
Vue.prototype.$off = functoin(event:Array<string>, fn){...}
Vue.prototype.$emit = functoin(event){...}
lifecyclesrc/core/instance/lifecycle.js
Vue.prototype._update = functoin(vnode, hydrating){...}
Vue.prototype.$forceUpdate = function(){...}
Vue.prototype.$destory = function(){...}
rendersrc/core/instance/render.js
Vue.prototype.$nexttick = function(fn){...}
Vue.prototype._render = function(){...}
其实在还有一个 initGlobalAPI(vm)
会初始化 .use()
, .extend()
, .mixin()
, 这些在分析过程中遇到再去了解
new Vue({})
之后)new Vue({
el: '#app',
data: {
name: {
firstName: 'lee',
lastName: 'les'
}
}
})
原谅我这个实例如此简单...
如果你记性好, 你就会知道 Vue 的所有一切 都是从一个_init(options)
开始的
现在来看揭开 _init
的神秘面纱
// path: src/core/instance/init.js
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
可以看到尤大, 在这里有一些注释, 个人认为这些注释一定要看并且好好理解, 因为这是最好的教程, 但是就算我们不懂, 我们依然可以判断出 _isComponent 这个属性是内部属性, 按照我们的正常流程走下去, 这个是不会用到的, 所以我们可以直接看else 语句里面的内容, 可以看到 Vue 实例化时做的第一件事情, 就是要合并Vue 的基本配置跟我们传进来的配置.
看到这里我们应该要提出一个问题, 就是,为什么要合并配置, 提出一个问题之后就是要自己先尝试着回答, 当自己一点头绪都没有时, 才是去询问别人的最好时机, 在这里我想, 这应该是方便读取配置信息, 因为他们都挂载在vm.$options上了 这样, 只要能访问this, 就能访问到配置信息
代码我就不贴了, Vue的基本配置 可以看 src/core/global-api/index.js
内容很简单, 深挖下去就知道 Vue.options 是 一个有 _base, components, filters,directives...
这些属性的对象, 合并了以后, 会加上你传进去的 属性, 在我们这个例子中就是 el
, data
.
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
这里可以看见明显的生命周期函数, 也知道了在beforeCreate 里并不能访问到this.xxx 来访问我们的data属性, 也能知道 inject 是先于 provide 初始化的 那么问题来啦,既然我们的data已经传了进去给Vue, Vue 怎么可能访问不了呢?
还记得, Vue 做的第一步操作是什么吗? 是合并$options
? 我们传进去的配置全都合并在了这个$options上了.
this.$options.data() // 尝试在beforeCreate() 钩子函数里面执行这段代码
//其实这个深度使用过Vue的人也可以很轻松的发现的(因为文档有提到$options)....
如果你正在看源码, 你还会看见一个 initProxy
, 我暂时不知道这段代码的作用, 就是拦截了 config.keyCodes
对象的一些属性设置
_init
的最后一步if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
如果你指定了挂载的Vue 容器, 那么Vue 就会直接挂载.
我们来看看Vue.$mount
这个Vue.$mount
要解释一下, 尤大在这里抽取了一个公共的 $mount
函数, 要看清楚入口文件才可以找到正确的$mount
函数
<details>
<summary>
$mount函数
</summary>
// src/platforms/web/entry-runtime-with-compiler.js
const mount = Vue.prototype.$mount // 对公共的$mount函数做个保存然后再覆盖
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
/* istanbul ignore if */
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to <html> or <body> - mount to normal elements instead.`
)
return this
}
const options = this.$options
// resolve template/el and convert to render function
// 解析 template 或者 el 然后转换成 render function
if (!options.render) {
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
/* istanbul ignore if */ // 性能检测
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
options.render = render
options.staticRenderFns = staticRenderFns
/* istanbul ignore if */
// 性能检测
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile end')
measure(`vue ${this._name} compile`, 'compile', 'compile end')
}
}
}
return mount.call(this, el, hydrating)
}
</details>
我们可以看到. 一开始就把原本的$mount
函数保存了一份, 然后再定义, 原本的$mount(el, hydrating)
只有几行代码, 建议自己看一下 src/platforms/web/runtime/index.js
在我们的实例中, 我们出了el和data其他什么都没有, 所以这里会用el
去getOuterHTML()
获取我们的模板, 也就是我们的#app
然后调用 compileToFunction
函数, 生成我们的render
函数(render函数式一个返回VNode
的函数),这个过程(涉及到AST => 抽象语法树)我们有需要再去学习,最后再调用共有的$mount(el, hydrating)
方法,然后就来到了我们的mountComponent(vm, el)
函数了.跟丢了没?
<details>
<summary>mountComponent(vm, el, hydrating)</summary>
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean // 初步估计是跟服务端渲染有关的
): Component {
// 现在的$el已经是一个DOM元素
vm.$el = el;
console.log((vm.$options.render),'mountComponent')
// 正常情况 到这里render 函数已早已成完毕, 这里的判断我猜是在预防render函数生成时出错的
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode // render 函数就是一个返回 VNode 的函数
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(`vue ${name} render`, startTag, endTag)
mark(startTag)
vm._update(vnode, hydrating)
mark(endTag)
measure(`vue ${name} patch`, startTag, endTag)
}
} else {
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
// we set this to vm._watcher inside the watcher's constructor
// since the watcher's initial patch may call $forceUpdate (e.g. inside child
// component's mounted hook), which relies on vm._watcher being already defined
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
// manually mounted instance, call mounted on self
// mounted is called for render-created child components in its inserted hook
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
</details>
可以看到这里最重要的操作,就是new Watcher()
watcher 是响应式的原理, 用于记录每一个需要更新的依赖, 跟Dep
相辅相成, 再配合 Object.definedProperty
, 完美!
但是我们渲染为什么要经过Warcher呢? 因为要收集依赖啊...
题外话, Watcher
也用于watch
的实现, 只不过我们当前的例子里并没有传入watch
.
要搞清楚他在这里干了什么, 先搞清楚传进去的参数, 可以看到一个比较复杂的updateComponent
现在我们来深入一下.先_render
再 _update
<details>
<summary>Vue.prototype._render</summary>
// src/core/instance/render.js
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
console.log(render, _parentVnode, '_parentVnode')
// 解析插槽
if (_parentVnode) {
vm.$scopedSlots = normalizeScopedSlots(
_parentVnode.data.scopedSlots,
vm.$slots,
vm.$scopedSlots
)
}
// set parent vnode. this allows render functions to have access
// to the data on the placeholder node.
vm.$vnode = _parentVnode
// render self
let vnode
try {
// There's no need to maintain a stack because all render fns are called
// separately from one another. Nested component's render fns are called
// when parent component is patched.
currentRenderingInstance = vm
vnode = render.call(vm._renderProxy, vm.$createElement)
} catch (e) {
handleError(e, vm, `render`)
// return error render result,
// or previous vnode to prevent render error causing blank component
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production' && vm.$options.renderError) {
try {
vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
} catch (e) {
handleError(e, vm, `renderError`)
vnode = vm._vnode
}
} else {
vnode = vm._vnode
}
} finally {
currentRenderingInstance = null
}
// if the returned array contains only a single node, allow it
if (Array.isArray(vnode) && vnode.length === 1) {
vnode = vnode[0]
}
// return empty vnode in case the render function errored out
if (!(vnode instanceof VNode)) {
if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
warn(
'Multiple root nodes returned from render function. Render function ' +
'should return a single root node.',
vm
)
}
vnode = createEmptyVNode()
}
// set parent
vnode.parent = _parentVnode
return vnode
}
</details>
首先我们这里没有_parentVnode
,也没有用到组件, 只是通过new Vue()
这种最简单的用法 所以父组件插槽是没有的.
所以这个函数通篇最重要的就是这一句代码
vnode = render.call(vm._renderProxy, vm.$createElement)
看尤大的注释就知道 render 可能回返回一个只有一个值的数组, 或者报错的时候会返回一个空的vnode, 其他操作都是兼容处理, 然后把vnode返回
<details>
<summary>_update</summary>
// src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
// Vue.prototype.__patch__ is injected in entry points
// based on the rendering backend used.
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// update __vue__ reference
if (prevEl) {
prevEl.__vue__ = null
}
if (vm.$el) {
vm.$el.__vue__ = vm
}
// if parent is an HOC, update its $el as well
if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
vm.$parent.$el = vm.$el
}
// updated hook is called by the scheduler to ensure that children are
// updated in a parent's updated hook.
}
</details>
这里最主要的就是 vm.$el = vm.__patch__(prevVnode, vnode)
, 通过patch
来挂载 vnode 并且比对两个vnode 的不用与相同, 这就是diff
, 在vue中 diff
跟 patch
是一起的. 这部分先略过, 我们先看整体.
watcher代码挺长的, 我就先贴个构造函数吧
<details>
<summary>Watcher constructor </summary>
constructor (
vm: Component, // Vue 实例
expOrFn: string | Function, // updateComponent
cb: Function, // 空函数
options?: ?Object, // {before: ()=>{}}
isRenderWatcher?: boolean // true 为了渲染收集依赖用的
) {
this.vm = vm
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
// options
if (options) {
this.deep = !!options.deep // 给watch属性用 如果watch属性是一个对象且deep为true 那么该对象就是深度watch 类似于深拷贝的概念
this.user = !!options.user // 如果为true 就是为 watche 属性服务的
this.lazy = !!options.lazy // lazy如果为true 的话就是computed属性的了, 只不过computed有缓存而已
this.sync = !!options.sync // 同步就立即执行cb 异步就队列执行cb
this.before = options.before // 刚好我们的参数就是有这个属性, 是一个回调函数
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
// 这里我们传进来的 expOrFn 就是一个 updateComponent() 就是一个函数
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
// 这里的parsePath 也不难, 回忆一下我们的 $watch 怎么用的?
/* 官方文档的例子
// 键路径
vm.$watch('a.b.c', function (newVal, oldVal) {
// 做点什么
})
可以看到我们的第一个参数, 'a.b.c' 其实这个表达式传进来就是我们的 expOrFn,
可以去看 $watch函数的代码 最终也还是要走 new Watcher 这一步的, parsePath就是为了把这个表达式的值给求出来
这个值是在vm实例上取得 一般在 data 里面最好, 不过在渲染过程中, 是不走这里的.
*/
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
this.value = this.lazy
? undefined
: this.get() // 求值, 其实就是触发我们的 getter 函数 触发 对象的 get 收集依赖, Vue 的响应式已经烂大街了 (有时间再写一篇), 在这里 这个值一求值, 我们的 updateComponent 就会执行, _render _updata 和会相应的执行, 然后就实现了我们的 mount 过程
}
</details>
至此, 我们的渲染过程已经学习完毕, 最主要的就是 整体的脉络非常的清晰, 真正需要下功夫的是 虚拟节点的 diff
patch
跟 template 到 render function 的转化. 共勉!
路漫漫其修远兮, 吾将上下而求索.
查看原文无规矩不成方圆 在技术领域上更是如此, 比如: 类名头字母大写, promiseA+ 规范, DOM 标准, es 标准, 都是规矩. 框架亦是如此, 比如Vue 就是尤大的一套规矩. 如果要打破规矩, 第一步要做的就是要了解规矩. 2.6版本 Vue 执行过程(new Vue({})之前) <details><...
赞 18 收藏 12 评论 0
李孤雏 评论了文章 · 2018-12-24
上一遍博文中,我们谈到了tapable的用法,现在我们来深入一下tap究竟是怎么运行的, 怎么处理,控制 tap 进去的钩子函数,拦截器又是怎么运行的.
先从同步函数开始分析,异步也就是回调而已;
这里有一个例子
let SyncHook = require('./lib/SyncHook.js')
let h1 = new SyncHook(['options']);
h1.tap('A', function (arg) {
console.log('A',arg);
return 'b'; // 除非你在拦截器上的 register 上调用这个函数,不然这个返回值你拿不到.
})
h1.tap('B', function () {
console.log('b')
})
h1.tap('C', function () {
console.log('c')
})
h1.tap('D', function () {
console.log('d')
})
h1.intercept({
call: (...args) => {
console.log(...args, '-------------intercept call');
},
//
register: (tap) => {
console.log(tap, '------------------intercept register');
return tap;
},
loop: (...args) => {
console.log(...args, '-------------intercept loop')
},
tap: (tap) => {
console.log(tap, '-------------------intercept tap')
}
})
h1.call(6);
new SyncHook(['synchook'])
首先先创建一个同步钩子对象,那这一步会干什么呢?
这一步会先执行超类Hook的初始化工作
// 初始化
constructor(args) {
// 参数必须是数组
if (!Array.isArray(args)) args = [];
// 把数组参数赋值给 _args 内部属性, new 的时候传进来的一系列参数.
this._args = args;
// 绑定taps,应该是事件
this.taps = [];
// 拦截器数组
this.interceptors = [];
// 暴露出去用于调用同步钩子的函数
this.call = this._call;
// 暴露出去的用于调用异步promise函数
this.promise = this._promise;
// 暴露出去的用于调用异步钩子函数
this.callAsync = this._callAsync;
// 用于生成调用函数的时候,保存钩子数组的变量,现在暂时先不管.
this._x = undefined;
}
.tap()
现在我们来看看调用了tap() 方法后发生了什么
tap(options, fn) {
// 下面是一些参数的限制,第一个参数必须是字符串或者是带name属性的对象,
// 用于标明钩子,并把钩子和名字都整合到 options 对象里面
if (typeof options === "string") options = { name: options };
if (typeof options !== "object" || options === null)
throw new Error(
"Invalid arguments to tap(options: Object, fn: function)"
);
options = Object.assign({ type: "sync", fn: fn }, options);
if (typeof options.name !== "string" || options.name === "")
throw new Error("Missing name for tap");
// 注册拦截器
options = this._runRegisterInterceptors(options);
// 插入钩子
this._insert(options);
}
_runRegisterInterceptors(options) {
// 现在这个参数应该是这个样子的{fn: function..., type: sync,name: 'A' }
// 遍历拦截器,有就应用,没有就把配置返还回去
for (const interceptor of this.interceptors) {
if (interceptor.register) {
// 把选项传入拦截器注册,从这里可以看出,拦截器的register 可以返回一个新的options选项,并且替换掉原来的options选项,也就是说可以在执行了一次register之后 改变你当初 tap 进去的方法
const newOptions = interceptor.register(options);
if (newOptions !== undefined) options = newOptions;
}
}
return options;
}
注意: 这里执行的register拦截器是有顺序问题的, 这个执行在tap()里面,也就是说,你这个拦截器要在调用tap(),之前就调用 intercept()添加的.
那拦截器是怎么添加进去的呢,来看下intercept()
intercept(interceptor) {
// 重置所有的 调用 方法,在教程中我们提到了 编译出来的调用方法依赖的其中一点就是 拦截器. 所有每添加一个拦截器都要重置一次调用方法,在下一次编译的时候,重新生成.
this._resetCompilation();
// 保存拦截器 而且是复制一份,保留原本的引用
this.interceptors.push(Object.assign({}, interceptor));
// 运行所有的拦截器的register函数并且把 taps[i],(tap对象) 传进去.
// 在intercept 的时候也会遍历执行一次当前所有的taps,把他们作为参数调用拦截器的register,并且把返回的 tap对象(tap对象就是指 tap函数里面把fn和name这些信息整合起来的那个对象) 替换了原来的 tap对象,所以register最好返回一个tap, 在例子中我返回了原来的tap, 但是其实最好返回一个全新的tap
if (interceptor.register) {
for (let i = 0; i < this.taps.length; i++)
this.taps[i] = interceptor.register(this.taps[i]);
}
}
注意: 也就是在调用tap() 之后再传入的拦截器,会在传入的时候就为每一个tap 调用register方法
_insert(item) {
// 重置资源,因为每一个插件都会有一个新的Compilation
this._resetCompilation();
// 顺序标记, 这里联合 __test__ 包里的Hook.js一起使用
// 看源码不懂,可以看他的测试代码,就知道他写的是什么目的.
// 从测试代码可以看到,这个 {before}是插件的名字.
let before;
// before 可以是单个字符串插件名称,也可以是一个字符串数组插件.
if (typeof item.before === "string") {
before = new Set([item.before]);
}
else if (Array.isArray(item.before)) {
before = new Set(item.before);
}
// 阶段
// 从测试代码可以知道这个也是一个控制顺序的属性,值越小,执行得就越在前面
// 而且优先级低于 before
let stage = 0;
if (typeof item.stage === "number") stage = item.stage;
let i = this.taps.length;
// 遍历所有`tap`了的函数,然后根据 stage 和 before 进行重新排序.
// 假设现在tap了 两个钩子 A B `B` 的配置是 {name: 'B', before: 'A'}
while (i > 0) {// i = 1, taps = [A]
i--;// i = 0 首先-- 是因为要从最后一个开始
const x = this.taps[i];// x = A
this.taps[i + 1] = x;// i = 0, taps[1] = A i+1 把当前元素往后移位,把位置让出来
const xStage = x.stage || 0;// xStage = 0
if (before) {// 如果有这个属性就会进入这个判断
if (before.has(x.name)) {// 如果before 有x.name 就会把这个插件名称从before这个列表里删除,代表这个钩子位置已经在当前的钩子之前
before.delete(x.name);
continue;// 如果before还有元素,继续循环,执行上面的操作
}
if (before.size > 0) {
continue;// 如果before还有元素,那就一直循环,直到第一位.
}
}
if (xStage > stage) {// 如果stage比当前钩子的stage大,继续往前挪
continue;
}
i++;
break;
}
this.taps[i] = item;// 把挪出来的位置插入传进来的钩子
}
这其实就是一个排序算法, 根据before, stage 的值来排序,也就是说你可以这样tap进来一个插件
h1.tap({
name: 'B',
before: 'A'
}, () => {
console.log('i am B')
})
发布订阅模式是一个在前后端都盛行的一个模式,前端的promise,事件,等等都基于发布订阅模式,其实tapable 也是一种发布订阅模式,上面的tap 只是订阅了钩子函数,我们还需要发布他,接下来我们谈谈h1.call()
,跟紧了,这里面才是重点.
我们可以在初始化中看到this.call = this._call
,那我们来看一下 this._call() 是个啥
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
_promise: {
value: createCompileDelegate("promise", "promise"),
configurable: true,
writable: true
},
_callAsync: {
value: createCompileDelegate("callAsync", "async"),
configurable: true,
writable: true
}
});
结果很明显,这个函数是由createCompileDelegate(),这个函数返回的,依赖于,函数的名字以及钩子的类型.
createCompileDelegate(name, type)
function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
// 子类调用时,this默认绑定到子类
// (不明白的可以了解js this指向,一个函数的this指向调用他的对象,没有就是全局,除非使用call apply bind 等改变指向)
// 在我们的例子中,这个 this 是 SyncHook
this[name] = this._createCall(type);
// 用args 去调用Call
return this[name](...args);
};
}
在上面的注释上可以加到,他通过闭包保存了name
跟type
的值,在我们这个例子中,这里就是this.call = this._createCall('sync');
然后把我们外部调用call(666) 时 传入的参数给到他编译生成的方法中.
注意,在我们这个例子当中我在call的时候并没有传入参数.
这时候这个call
方法的重点就在_createCall
方法里面了.
_createCall(type) {
// 传递一个整合了各个依赖条件的对象给子类的compile方法
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
从一开始,我们就在Hook.js上分析,我们来看看Hook上的compile
compile(options) {
throw new Error("Abstract: should be overriden");
}
清晰明了,这个方法一定要子类复写,不然报错,上面的_createCompileDelegate
的注释也写得很清楚,在当前的上下文中,this指向的是,子类,在我们这个例子中就是SyncHook
SyncHook
的compilecompile(options) {
// 现在options 是由Hook里面 传到这里的
// options
// {
// taps: this.taps, tap对象数组
// interceptors: this.interceptors, 拦截器数组
// args: this._args,
// type: type
// }
// 对应回教程中的编译出来的调用函数依赖于的那几项看看,是不是这些,钩子的个数,new SyncHook(['arg'])的参数个数,拦截器的个数,钩子的类型.
factory.setup(this, options);
return factory.create(options);
}
好吧 现在来看看setup, 咦? factory 怎么来的,原来
const factory = new SyncHookCodeFactory();
是new 出来的
constructor(config) {
// 这个config作用暂定.因为我看了这个文件,没看到有引用的地方,
// 应该是其他子类有引用到
this.config = config;
// 这两个不难懂, 往下看就知道了
this.options = undefined;
this._args = undefined;
}
setup(instance, options) {
// 这里的instance 是syncHook 实例, 其实就是把tap进来的钩子数组给到钩子的_x属性里.
instance._x = options.taps.map(t => t.fn);
}
OK, 到create了
create(options) {
// 初始化参数,保存options到本对象this.options,保存new Hook(["options"]) 传入的参数到 this._args
this.init(options);
let fn;
// 动态构建钩子,这里是抽象层,分同步, 异步, promise
switch (this.options.type) {
// 先看同步
case "sync":
// 动态返回一个钩子函数
fn = new Function(
// 生成函数的参数,no before no after 返回参数字符串 xxx,xxx 在
// 注意这里this.args返回的是一个字符串,
// 在这个例子中是options
this.args(),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() +
// 这个 content 调用的是子类类的 content 函数,
// 参数由子类传,实际返回的是 this.callTapsSeries() 返回的类容
this.content({
onError: err => `_callback(${err});\n`,
onResult: result => `_callback(null, ${result});\n`,
onDone: () => "_callback();\n"
})
);
break;
case "promise":
let code = "";
code += '"use strict";\n';
code += "return new Promise((_resolve, _reject) => {\n";
code += "var _sync = true;\n";
code += this.header();
code += this.content({
onError: err => {
let code = "";
code += "if(_sync)\n";
code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
code += "else\n";
code += `_reject(${err});\n`;
return code;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
code += "_sync = false;\n";
code += "});\n";
fn = new Function(this.args(), code);
break;
}
// 把刚才init赋的值初始化为undefined
// this.options = undefined;
// this._args = undefined;
this.deinit();
return fn;
}
到了这个方法,一切我们都一目了然了(看content的参数), 在我们的例子中他是通过动态的生成一个call方法,根据的条件有,钩子是否有context 属性(这个是根据header的代码才能知道), 钩子的个数, 钩子的类型,钩子的参数,钩子的拦截器个数.
注意,这上面有关于 fn这个变量的函数,返回的都是字符串,不是函数不是方法,是返回可以转化成代码执行的字符串,思维要转变过来.
现在我们来看看header()
header() {
let code = "";
// this.needContext() 判断taps[i] 是否 有context 属性, 任意一个tap有 都会返回 true
if (this.needContext()) {
// 如果有context 属性, 那_context这个变量就是一个空的对象.
code += "var _context = {};\n";
} else {
// 否则 就是undefined
code += "var _context;\n";
}
// 在setup()中 把所有tap对象的钩子 都给到了 instance ,这里的this 就是setup 中的instance _x 就是钩子对象数组
code += "var _x = this._x;\n";
// 如果有拦截器,在我们的例子中,就有一个拦截器
if (this.options.interceptors.length > 0) {
// 保存taps 数组到_taps变量, 保存拦截器数组 到变量_interceptors
code += "var _taps = this.taps;\n";
code += "var _interceptors = this.interceptors;\n";
}
// 如果没有拦截器, 这里也不会执行.一个拦截器只会生成一次call
// 在我们的例子中,就有一个拦截器,就有call
for (let i = 0; i < this.options.interceptors.length; i++) {
const interceptor = this.options.interceptors[i];
if (interceptor.call) {
// getInterceptor 返回的 是字符串 是 `_interceptors[i]`
// 后面的before 因为我们的拦截器没有context 所以返回的是undefined 所以后面没有跟一个空对象
code += `${this.getInterceptor(i)}.call(${this.args({
before: interceptor.context ? "_context" : undefined
})});\n`;
}
}
return code;
// 注意 header 返回的不是代码,是可以转化成代码的字符串(这个时候并没有执行).
/**
* 此时call函数应该为:
* "use strict";
* function (options) {
* var _context;
* var _x = this._x;
* var _taps = this.taps;
* var _interterceptors = this.interceptors;
* // 我们只有一个拦截器所以下面的只会生成一个
* _interceptors[0].call(options);
*}
*/
}
现在到我们的this.content()
了,仔细一看,this.content()
方法并不在HookCodeFactory
上,很明显这个content是由子类来实现的,往回看看这个create是由谁调用的?没错,是SuncHookCodeFactory的石料理,我们来看看SyncHook.js
上的SyncHookCodeFactory
实现的content
在看这个content实现之前,先来回顾一下父类的create()
给他传了什么参数.
this.content({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
onDone: () => "",
rethrowIfPossible: true
})
注意了,这上面不是抛出错误,不是返回值. 这里面的回调执行了以后返回的是一个字符串,不要搞混了代码与可以转化成代码的字符串.
content({ onError, onResult, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
// 可以在这改变onError 但是这里的 i 并没有用到,这是什么操作...
// 注意这里并没有传入onResult
onError: (i, err) => onError(err),
onDone,
// 这个默认为true
rethrowIfPossible
});
}
这个函数返回什么取决于this.callTapSeries(), 那接下来我们来看看这个函数(这层层嵌套,其实也是有可斟酌的地方.看源码不仅要看实现,代码的组织也是很重要的编码能力)
刚才函数的头部已经出来了,头部做了初始化的操作,与生成执行拦截器代码.content很明显,要开始生成执行我们的tap对象的代码了(如果不然,我们的tap进来的函数在哪里执行呢? 滑稽:).
callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
// 如果 taps 钩子处理完毕,执行onDone,或者一个tap都没有 onDone() 返回的是一个字符串.看上面的回顾就知道了.
if (this.options.taps.length === 0) return onDone();
// 如果由异步钩子,把第一个异步钩子的下标,如果没有这个返回的是-1
const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");
// 定义一个函数 接受一个 number 类型的参数, i 应该是taps的index
// 从这个函数的命名来看,这个函数应该会递归的执行
// 我们先开最后的return语句,发现第一个传进来的参数是0
const next = i => {
// 如果 大于等于钩子函数数组长度, 返回并执行onDone回调,就是tap对象都处理完了
// 跳出递归的条件
if (i >= this.options.taps.length) {
return onDone();
}
// 这个方法就是递归的关键,看见没,逐渐往上遍历
// 注意这里只是定义了方法,并没有执行
const done = () => next(i + 1);
// 传入一个值 如果是false 就执行onDone true 返回一个 ""
// 字面意思,是否跳过done 应该是增加一个跳出递归的条件
const doneBreak = skipDone => {
if (skipDone) return "";
return onDone();
};
// 这里就是处理单个taps对象的关键,传入一个下标,和一系列回调.
return this.callTap(i, {
// 调用的onError 是 (i, err) => onError(err) , 后面这个onError(err)是 () => `throw ${err}`
// 目前 i done doneBreak 都没有用到
onError: error => onError(i, error, done, doneBreak),
// 这里onResult 同步钩子的情况下在外部是没有传进来的,刚才也提到了
// 这里onResult是 undefined
onResult:
onResult &&
(result => {
return onResult(i, result, done, doneBreak);
}),
// 没有onResult 一定要有一个onDone 所以这里就是一个默认的完成回调
// 这里的done 执行的是next(i+1), 也就是迭代的处理完所有的taps
onDone:
!onResult &&
(() => {return done();}),
// rethrowIfPossible 默认是 true 也就是返回后面的
// 因为没有异步函数 firstAsync = -1.
// 所以返回的是 -1 < 0,也就是true, 这个可以判断当前的是否是异步的tap对象
// 这里挺妙的 如果是 false 那么当前的钩子类型就不是sync,可能是promise或者是async
// 具体作用要看callTaps()如何使用这个.
rethrowIfPossible:
rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
});
};
return next(0);
}
callTap()
了.callTap
挺长的,因为他也分了3种类型分别处理,像create()一样.
/** tapIndex 下标
* onError:() => onError(i,err,done,skipdone) ,
* onReslt: undefined
* onDone: () => {return: done()} //开启递归的钥匙
* rethrowIfPossible: false 说明当前的钩子不是sync的.
*/
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
let code = "";
// hasTapCached 是否有tap的缓存, 这个要看看他是怎么做的缓存了
let hasTapCached = false;
// 这里还是拦截器的用法,如果有就执行拦截器的tap函数
for (let i = 0; i < this.options.interceptors.length; i++) {
const interceptor = this.options.interceptors[i];
if (interceptor.tap) {
if (!hasTapCached) {
// 这里getTap返回的是 _taps[0] _taps[1]... 的字符串
// 这里生成的代码就是 `var _tap0 = _taps[0]`
// 注意: _taps 变量我们在 header 那里已经生成了
code += `var _tap${tapIndex} = ${this.getTap(tapIndex)};\n`;
// 可以看到这个变量的作用就是,如果有多个拦截器.这里也只会执行一次.
// 注意这句获取_taps 对象的下标用的是tapIndex,在一次循环中,这个tapIndex不会变
// 就是说如果这里执行多次,就会生成多个重复代码,不稳定,也影响性能.
// 但是你又要判断拦截器有没有tap才可以执行,或许有更好的写法
// 如果你能想到,那么你就是webpack的贡献者了.不过这样写,似乎也没什么不好.
hasTapCached = true;
}
// 这里很明显跟上面的getTap 一样 返回的都是字符串
// 我就直接把这里的code 分析出来了,注意 这里还是在循坏中.
// code += _interceptor[0].tap(_tap0);
// 由于我们的拦截器没有context,所以没传_context进来.
// 可以看到这里是调用拦截器的tap方法然后传入tap0对象的地方
code += `${this.getInterceptor(i)}.tap(${
interceptor.context ? "_context, " : ""
}_tap${tapIndex});\n`;
}
}
// 跑出了循坏
// 这里的getTapFn 返回的也是字符串 `_x[0]`
// callTap用到的这些全部在header() 那里生成了,忘记的回头看一下.
// 这里的code就是: var _fn0 = _x[0]
code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
const tap = this.options.taps[tapIndex];
// 开始处理tap 对象
switch (tap.type) {
case "sync":
// 全是同步的时候, 这里不执行, 如果有异步函数,那么恭喜,有可能会报错.所以他加了个 try...catch
if (!rethrowIfPossible) {
code += `var _hasError${tapIndex} = false;\n`;
code += "try {\n";
}
// 前面分析了 同步的时候 onResult 是 undefined
// 我们也分析一下如果走这里会怎样
// var _result0 = _fn0(option)
// 可以看到是调用tap 进来的钩子并且接收参数
if (onResult) {
code += `var _result${tapIndex} = _fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined
})});\n`;
} else {
// 所以会走这里
// _fn0(options) 额... 我日 有就接受一下结果
code += `_fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined
})});\n`;
}
// 把 catch 补上,在这个例子中没有
if (!rethrowIfPossible) {
code += "} catch(_err) {\n";
code += `_hasError${tapIndex} = true;\n`;
code += onError("_err");
code += "}\n";
code += `if(!_hasError${tapIndex}) {\n`;
}
// 有onResult 就把结果给传递出去. 目前没有
if (onResult) {
code += onResult(`_result${tapIndex}`);
}
// 有onDone() 就调用他开始递归,还记得上面的next(i+1) 吗?
if (onDone) {
code += onDone();
}
// 这里是不上上面的if的大括号,在这个例子中没有,所以这里也不执行
if (!rethrowIfPossible) {
code += "}\n";
}
// 同步情况下, 这里最终的代码就是
// var _tap0 = _taps[0];
// _interceptors[0].tap(_tap0);
// var _fn0 = _x[0];
// _fn0(options);
// 可以看到,这里会递归下去
// 因为我们tap了4个钩子
// 所以这里会从复4次
// 最终长这样
// var _tap0 = _taps[0];
// _interceptors[0].tap(_tap0);
// var _fn0 = _x[0];
// _fn0(options);
// var _tap1 = _taps[1];
// _interceptors[1].tap(_tap1);
// var _fn1 = _x[1];
// _fn1(options);
// ......
break;
case "async":
let cbCode = "";
if (onResult) cbCode += `(_err${tapIndex}, _result${tapIndex}) => {\n`;
else cbCode += `_err${tapIndex} => {\n`;
cbCode += `if(_err${tapIndex}) {\n`;
cbCode += onError(`_err${tapIndex}`);
cbCode += "} else {\n";
if (onResult) {
cbCode += onResult(`_result${tapIndex}`);
}
if (onDone) {
cbCode += onDone();
}
cbCode += "}\n";
cbCode += "}";
code += `_fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined,
after: cbCode
})});\n`;
break;
case "promise":
code += `var _hasResult${tapIndex} = false;\n`;
code += `var _promise${tapIndex} = _fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined
})});\n`;
code += `if (!_promise${tapIndex} || !_promise${tapIndex}.then)\n`;
code += ` throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise${tapIndex} + ')');\n`;
code += `_promise${tapIndex}.then(_result${tapIndex} => {\n`;
code += `_hasResult${tapIndex} = true;\n`;
if (onResult) {
code += onResult(`_result${tapIndex}`);
}
if (onDone) {
code += onDone();
}
code += `}, _err${tapIndex} => {\n`;
code += `if(_hasResult${tapIndex}) throw _err${tapIndex};\n`;
code += onError(`_err${tapIndex}`);
code += "});\n";
break;
}
return code;
}
好了, 到了这里 我们可以把compile 出来的call 方法输出出来了
"use strict";
function (options) {
var _context;
var _x = this._x;
var _taps = this.taps;
var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
_interceptors[0].call(options);
var _tap0 = _taps[0];
_interceptors[0].tap(_tap0);
var _fn0 = _x[0];
_fn0(options);
var _tap1 = _taps[1];
_interceptors[1].tap(_tap1);
var _fn1 = _x[1];
_fn1(options);
var _tap2 = _taps[2];
_interceptors[2].tap(_tap2);
var _fn2 = _x[2];
_fn2(options);
var _tap3 = _taps[3];
_interceptors[3].tap(_tap3);
var _fn3 = _x[3];
_fn3(options);
}
到了这里可以知道,我们的例子中h1.call()
其实调用的就是这个方法.到此我们可以说是知道了这个库的百分之80了.
不知道大家有没有发现,这个生成的函数的参数列表是从哪里来的呢?往回翻到create()方法里面调用的this.args()
你就会看见,没错就是this._args. 这个东西在哪里初始化呢? 翻一下就知道,这是在Hook.js
这个类里面初始化的,也就是说你h1 = new xxxHook(['options'])
的时候传入的数组有几个值,那么你h1.call({name: 'haha'})
就能传几个值.看教程的时候他说,这里传入的是一个参数名字的字符串列表,那时候我就纳闷,什么鬼,我传入的不是值吗,怎么就变成了参数名称,现在完全掌握....
好了,最简单的SyncHook
已经搞掂,但是一看tapable
内部核心使用的钩子却不是他,而是SyncBailHook
,在教程中我们已经知道,bail
是只要有一个钩子执行完了,并且返回一个值,那么其他的钩子就不执行.我们来看看他是怎么实现的.
从刚才我们弄明白的synchook
,我们知道了他的套路,其实生成的函数的header()
都是一样的,这次我们直接来看看bailhook
实现的content()
方法
content({ onError, onResult, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
// 看回callTapsSeries 就知道这里传入的next 是 done
onResult: (i, result, next) =>
`if(${result} !== undefined) {\n${onResult(
result
)};\n} else {\n${next()}}\n`,
onDone,
rethrowIfPossible
});
}
看出来了哪里不一样吗? 是的bailhook
的 callTapsSeries
传了onResult
属性,我们来看看他这个onResult是啥黑科技
父类传的onResult
默认是 (result) => 'return ${result}'
,那么他这里返回的就是:
// 下面返回的是字符串,
if (xxx !== undefined) {
// 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
return result;
} else {
// next(); 这里返回的是一个字符串(因为要生成字符串代码)
// 我在上面的注释中提到了 next 是 done 就是那个开启递归的门
// 所以如果tap 一直没返回值, 这里就会一直 if...else.. 的嵌套下去
}
回头想想,我们刚刚是不是分析了capTap()
,如果我们传了onResult
会怎样? 如果你还记得就知道,如果有传了onResult
这个回调,他就会接收这个返回值.并且会调用这个回调把result
传出去.
而且还要注意的是,onDone
在callTap()
的时候是处理过的,我在贴出来一次.
onDone:!onResult && (() => {return done();})
也就是说如果我传了onResult
那么这个onDone
就是一个false
.
所以递归的门现在从sync
的onDone
,变到syncBail
的onResult
了
好,现在带着这些变化去看this.capTap()
,你就能推出现在这个 call 函数会变成这样.
"use strict";
function (options) {
var _context;
var _x = this._x;
var _taps = this.taps;
var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
_interceptors[0].call(options);
var _tap0 = _taps[0];
_interceptors[0].tap(_tap0);
var _fn0 = _x[0];
var _result0 = _fn0(options);
if (_result0 !== undefined) {
// 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
return _result0
} else {
var _tap1 = _taps[1];
_interceptors[1].tap(_tap1);
var _fn1 = _x[1];
var _result1 = _fn1(options);
if (_result1 !== undefined) {
return _result1
} else {
var _tap2 = _taps[2];
_interceptors[2].tap(_tap2);
var _fn2 = _x[2];
var _result2 = _fn2(options);
if (_result2 !== undefined) {
return _result2
} else {
var _tap3 = _taps[3];
_interceptors[3].tap(_tap3);
var _fn3 = _x[3];
_fn3(options);
}
}
}
到如今,tapable库 已经删除了 tapable.js文件(可能做了一些整合,更细分了),只留下了钩子文件.但不影响功能,webpack 里的compile
compilation
等一众重要插件,都是基于 tapable库中的这些钩子.
现在我们require('tapable')得到的对象是这样的:
{
SyncHook: function(...){},
SyncBailHook: function(...){},
...
}
到此,关于tapable的大部分我都解剖了一遍,还有其他类型的hook
如果你们愿意,相信你们去研究一下,也能够游刃有余.
那个,写得有些随性,可能会让你们觉得模糊,但是...我真尽力了,这篇改了几遍,历时一个星期...,不懂就在那个评论区问我.我看到会回复的.共勉.
后记:
本来以为会很难,但是越往下深入的时候发现,大神之所以成为大神,不是他的代码写得牛,是他的思维牛,没有看不懂的代码,只有跟不上的思路,要看懂他如何把call 函数组织出来不难,难的是,他居然能想到这样来生成函数,还可以考虑到,拦截器钩子,和context
属性,以及他的 onResult
onDone
回调的判断,架构的设计,等等,一步接一步.先膜拜吧...
路漫漫其修远兮, 吾将上下而求索.
查看原文上一遍博文中,我们谈到了tapable的用法,现在我们来深入一下tap究竟是怎么运行的, 怎么处理,控制 tap 进去的钩子函数,拦截器又是怎么运行的.
李孤雏 发布了文章 · 2018-12-18
上一遍博文中,我们谈到了tapable的用法,现在我们来深入一下tap究竟是怎么运行的, 怎么处理,控制 tap 进去的钩子函数,拦截器又是怎么运行的.
先从同步函数开始分析,异步也就是回调而已;
这里有一个例子
let SyncHook = require('./lib/SyncHook.js')
let h1 = new SyncHook(['options']);
h1.tap('A', function (arg) {
console.log('A',arg);
return 'b'; // 除非你在拦截器上的 register 上调用这个函数,不然这个返回值你拿不到.
})
h1.tap('B', function () {
console.log('b')
})
h1.tap('C', function () {
console.log('c')
})
h1.tap('D', function () {
console.log('d')
})
h1.intercept({
call: (...args) => {
console.log(...args, '-------------intercept call');
},
//
register: (tap) => {
console.log(tap, '------------------intercept register');
return tap;
},
loop: (...args) => {
console.log(...args, '-------------intercept loop')
},
tap: (tap) => {
console.log(tap, '-------------------intercept tap')
}
})
h1.call(6);
new SyncHook(['synchook'])
首先先创建一个同步钩子对象,那这一步会干什么呢?
这一步会先执行超类Hook的初始化工作
// 初始化
constructor(args) {
// 参数必须是数组
if (!Array.isArray(args)) args = [];
// 把数组参数赋值给 _args 内部属性, new 的时候传进来的一系列参数.
this._args = args;
// 绑定taps,应该是事件
this.taps = [];
// 拦截器数组
this.interceptors = [];
// 暴露出去用于调用同步钩子的函数
this.call = this._call;
// 暴露出去的用于调用异步promise函数
this.promise = this._promise;
// 暴露出去的用于调用异步钩子函数
this.callAsync = this._callAsync;
// 用于生成调用函数的时候,保存钩子数组的变量,现在暂时先不管.
this._x = undefined;
}
.tap()
现在我们来看看调用了tap() 方法后发生了什么
tap(options, fn) {
// 下面是一些参数的限制,第一个参数必须是字符串或者是带name属性的对象,
// 用于标明钩子,并把钩子和名字都整合到 options 对象里面
if (typeof options === "string") options = { name: options };
if (typeof options !== "object" || options === null)
throw new Error(
"Invalid arguments to tap(options: Object, fn: function)"
);
options = Object.assign({ type: "sync", fn: fn }, options);
if (typeof options.name !== "string" || options.name === "")
throw new Error("Missing name for tap");
// 注册拦截器
options = this._runRegisterInterceptors(options);
// 插入钩子
this._insert(options);
}
_runRegisterInterceptors(options) {
// 现在这个参数应该是这个样子的{fn: function..., type: sync,name: 'A' }
// 遍历拦截器,有就应用,没有就把配置返还回去
for (const interceptor of this.interceptors) {
if (interceptor.register) {
// 把选项传入拦截器注册,从这里可以看出,拦截器的register 可以返回一个新的options选项,并且替换掉原来的options选项,也就是说可以在执行了一次register之后 改变你当初 tap 进去的方法
const newOptions = interceptor.register(options);
if (newOptions !== undefined) options = newOptions;
}
}
return options;
}
注意: 这里执行的register拦截器是有顺序问题的, 这个执行在tap()里面,也就是说,你这个拦截器要在调用tap(),之前就调用 intercept()添加的.
那拦截器是怎么添加进去的呢,来看下intercept()
intercept(interceptor) {
// 重置所有的 调用 方法,在教程中我们提到了 编译出来的调用方法依赖的其中一点就是 拦截器. 所有每添加一个拦截器都要重置一次调用方法,在下一次编译的时候,重新生成.
this._resetCompilation();
// 保存拦截器 而且是复制一份,保留原本的引用
this.interceptors.push(Object.assign({}, interceptor));
// 运行所有的拦截器的register函数并且把 taps[i],(tap对象) 传进去.
// 在intercept 的时候也会遍历执行一次当前所有的taps,把他们作为参数调用拦截器的register,并且把返回的 tap对象(tap对象就是指 tap函数里面把fn和name这些信息整合起来的那个对象) 替换了原来的 tap对象,所以register最好返回一个tap, 在例子中我返回了原来的tap, 但是其实最好返回一个全新的tap
if (interceptor.register) {
for (let i = 0; i < this.taps.length; i++)
this.taps[i] = interceptor.register(this.taps[i]);
}
}
注意: 也就是在调用tap() 之后再传入的拦截器,会在传入的时候就为每一个tap 调用register方法
_insert(item) {
// 重置资源,因为每一个插件都会有一个新的Compilation
this._resetCompilation();
// 顺序标记, 这里联合 __test__ 包里的Hook.js一起使用
// 看源码不懂,可以看他的测试代码,就知道他写的是什么目的.
// 从测试代码可以看到,这个 {before}是插件的名字.
let before;
// before 可以是单个字符串插件名称,也可以是一个字符串数组插件.
if (typeof item.before === "string") {
before = new Set([item.before]);
}
else if (Array.isArray(item.before)) {
before = new Set(item.before);
}
// 阶段
// 从测试代码可以知道这个也是一个控制顺序的属性,值越小,执行得就越在前面
// 而且优先级低于 before
let stage = 0;
if (typeof item.stage === "number") stage = item.stage;
let i = this.taps.length;
// 遍历所有`tap`了的函数,然后根据 stage 和 before 进行重新排序.
// 假设现在tap了 两个钩子 A B `B` 的配置是 {name: 'B', before: 'A'}
while (i > 0) {// i = 1, taps = [A]
i--;// i = 0 首先-- 是因为要从最后一个开始
const x = this.taps[i];// x = A
this.taps[i + 1] = x;// i = 0, taps[1] = A i+1 把当前元素往后移位,把位置让出来
const xStage = x.stage || 0;// xStage = 0
if (before) {// 如果有这个属性就会进入这个判断
if (before.has(x.name)) {// 如果before 有x.name 就会把这个插件名称从before这个列表里删除,代表这个钩子位置已经在当前的钩子之前
before.delete(x.name);
continue;// 如果before还有元素,继续循环,执行上面的操作
}
if (before.size > 0) {
continue;// 如果before还有元素,那就一直循环,直到第一位.
}
}
if (xStage > stage) {// 如果stage比当前钩子的stage大,继续往前挪
continue;
}
i++;
break;
}
this.taps[i] = item;// 把挪出来的位置插入传进来的钩子
}
这其实就是一个排序算法, 根据before, stage 的值来排序,也就是说你可以这样tap进来一个插件
h1.tap({
name: 'B',
before: 'A'
}, () => {
console.log('i am B')
})
发布订阅模式是一个在前后端都盛行的一个模式,前端的promise,事件,等等都基于发布订阅模式,其实tapable 也是一种发布订阅模式,上面的tap 只是订阅了钩子函数,我们还需要发布他,接下来我们谈谈h1.call()
,跟紧了,这里面才是重点.
我们可以在初始化中看到this.call = this._call
,那我们来看一下 this._call() 是个啥
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
_promise: {
value: createCompileDelegate("promise", "promise"),
configurable: true,
writable: true
},
_callAsync: {
value: createCompileDelegate("callAsync", "async"),
configurable: true,
writable: true
}
});
结果很明显,这个函数是由createCompileDelegate(),这个函数返回的,依赖于,函数的名字以及钩子的类型.
createCompileDelegate(name, type)
function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
// 子类调用时,this默认绑定到子类
// (不明白的可以了解js this指向,一个函数的this指向调用他的对象,没有就是全局,除非使用call apply bind 等改变指向)
// 在我们的例子中,这个 this 是 SyncHook
this[name] = this._createCall(type);
// 用args 去调用Call
return this[name](...args);
};
}
在上面的注释上可以加到,他通过闭包保存了name
跟type
的值,在我们这个例子中,这里就是this.call = this._createCall('sync');
然后把我们外部调用call(666) 时 传入的参数给到他编译生成的方法中.
注意,在我们这个例子当中我在call的时候并没有传入参数.
这时候这个call
方法的重点就在_createCall
方法里面了.
_createCall(type) {
// 传递一个整合了各个依赖条件的对象给子类的compile方法
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
从一开始,我们就在Hook.js上分析,我们来看看Hook上的compile
compile(options) {
throw new Error("Abstract: should be overriden");
}
清晰明了,这个方法一定要子类复写,不然报错,上面的_createCompileDelegate
的注释也写得很清楚,在当前的上下文中,this指向的是,子类,在我们这个例子中就是SyncHook
SyncHook
的compilecompile(options) {
// 现在options 是由Hook里面 传到这里的
// options
// {
// taps: this.taps, tap对象数组
// interceptors: this.interceptors, 拦截器数组
// args: this._args,
// type: type
// }
// 对应回教程中的编译出来的调用函数依赖于的那几项看看,是不是这些,钩子的个数,new SyncHook(['arg'])的参数个数,拦截器的个数,钩子的类型.
factory.setup(this, options);
return factory.create(options);
}
好吧 现在来看看setup, 咦? factory 怎么来的,原来
const factory = new SyncHookCodeFactory();
是new 出来的
constructor(config) {
// 这个config作用暂定.因为我看了这个文件,没看到有引用的地方,
// 应该是其他子类有引用到
this.config = config;
// 这两个不难懂, 往下看就知道了
this.options = undefined;
this._args = undefined;
}
setup(instance, options) {
// 这里的instance 是syncHook 实例, 其实就是把tap进来的钩子数组给到钩子的_x属性里.
instance._x = options.taps.map(t => t.fn);
}
OK, 到create了
create(options) {
// 初始化参数,保存options到本对象this.options,保存new Hook(["options"]) 传入的参数到 this._args
this.init(options);
let fn;
// 动态构建钩子,这里是抽象层,分同步, 异步, promise
switch (this.options.type) {
// 先看同步
case "sync":
// 动态返回一个钩子函数
fn = new Function(
// 生成函数的参数,no before no after 返回参数字符串 xxx,xxx 在
// 注意这里this.args返回的是一个字符串,
// 在这个例子中是options
this.args(),
'"use strict";\n' +
this.header() +
this.content({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
onDone: () => "",
rethrowIfPossible: true
})
);
break;
case "async":
fn = new Function(
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() +
// 这个 content 调用的是子类类的 content 函数,
// 参数由子类传,实际返回的是 this.callTapsSeries() 返回的类容
this.content({
onError: err => `_callback(${err});\n`,
onResult: result => `_callback(null, ${result});\n`,
onDone: () => "_callback();\n"
})
);
break;
case "promise":
let code = "";
code += '"use strict";\n';
code += "return new Promise((_resolve, _reject) => {\n";
code += "var _sync = true;\n";
code += this.header();
code += this.content({
onError: err => {
let code = "";
code += "if(_sync)\n";
code += `_resolve(Promise.resolve().then(() => { throw ${err}; }));\n`;
code += "else\n";
code += `_reject(${err});\n`;
return code;
},
onResult: result => `_resolve(${result});\n`,
onDone: () => "_resolve();\n"
});
code += "_sync = false;\n";
code += "});\n";
fn = new Function(this.args(), code);
break;
}
// 把刚才init赋的值初始化为undefined
// this.options = undefined;
// this._args = undefined;
this.deinit();
return fn;
}
到了这个方法,一切我们都一目了然了(看content的参数), 在我们的例子中他是通过动态的生成一个call方法,根据的条件有,钩子是否有context 属性(这个是根据header的代码才能知道), 钩子的个数, 钩子的类型,钩子的参数,钩子的拦截器个数.
注意,这上面有关于 fn这个变量的函数,返回的都是字符串,不是函数不是方法,是返回可以转化成代码执行的字符串,思维要转变过来.
现在我们来看看header()
header() {
let code = "";
// this.needContext() 判断taps[i] 是否 有context 属性, 任意一个tap有 都会返回 true
if (this.needContext()) {
// 如果有context 属性, 那_context这个变量就是一个空的对象.
code += "var _context = {};\n";
} else {
// 否则 就是undefined
code += "var _context;\n";
}
// 在setup()中 把所有tap对象的钩子 都给到了 instance ,这里的this 就是setup 中的instance _x 就是钩子对象数组
code += "var _x = this._x;\n";
// 如果有拦截器,在我们的例子中,就有一个拦截器
if (this.options.interceptors.length > 0) {
// 保存taps 数组到_taps变量, 保存拦截器数组 到变量_interceptors
code += "var _taps = this.taps;\n";
code += "var _interceptors = this.interceptors;\n";
}
// 如果没有拦截器, 这里也不会执行.一个拦截器只会生成一次call
// 在我们的例子中,就有一个拦截器,就有call
for (let i = 0; i < this.options.interceptors.length; i++) {
const interceptor = this.options.interceptors[i];
if (interceptor.call) {
// getInterceptor 返回的 是字符串 是 `_interceptors[i]`
// 后面的before 因为我们的拦截器没有context 所以返回的是undefined 所以后面没有跟一个空对象
code += `${this.getInterceptor(i)}.call(${this.args({
before: interceptor.context ? "_context" : undefined
})});\n`;
}
}
return code;
// 注意 header 返回的不是代码,是可以转化成代码的字符串(这个时候并没有执行).
/**
* 此时call函数应该为:
* "use strict";
* function (options) {
* var _context;
* var _x = this._x;
* var _taps = this.taps;
* var _interterceptors = this.interceptors;
* // 我们只有一个拦截器所以下面的只会生成一个
* _interceptors[0].call(options);
*}
*/
}
现在到我们的this.content()
了,仔细一看,this.content()
方法并不在HookCodeFactory
上,很明显这个content是由子类来实现的,往回看看这个create是由谁调用的?没错,是SuncHookCodeFactory的石料理,我们来看看SyncHook.js
上的SyncHookCodeFactory
实现的content
在看这个content实现之前,先来回顾一下父类的create()
给他传了什么参数.
this.content({
onError: err => `throw ${err};\n`,
onResult: result => `return ${result};\n`,
onDone: () => "",
rethrowIfPossible: true
})
注意了,这上面不是抛出错误,不是返回值. 这里面的回调执行了以后返回的是一个字符串,不要搞混了代码与可以转化成代码的字符串.
content({ onError, onResult, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
// 可以在这改变onError 但是这里的 i 并没有用到,这是什么操作...
// 注意这里并没有传入onResult
onError: (i, err) => onError(err),
onDone,
// 这个默认为true
rethrowIfPossible
});
}
这个函数返回什么取决于this.callTapSeries(), 那接下来我们来看看这个函数(这层层嵌套,其实也是有可斟酌的地方.看源码不仅要看实现,代码的组织也是很重要的编码能力)
刚才函数的头部已经出来了,头部做了初始化的操作,与生成执行拦截器代码.content很明显,要开始生成执行我们的tap对象的代码了(如果不然,我们的tap进来的函数在哪里执行呢? 滑稽:).
callTapsSeries({ onError, onResult, onDone, rethrowIfPossible }) {
// 如果 taps 钩子处理完毕,执行onDone,或者一个tap都没有 onDone() 返回的是一个字符串.看上面的回顾就知道了.
if (this.options.taps.length === 0) return onDone();
// 如果由异步钩子,把第一个异步钩子的下标,如果没有这个返回的是-1
const firstAsync = this.options.taps.findIndex(t => t.type !== "sync");
// 定义一个函数 接受一个 number 类型的参数, i 应该是taps的index
// 从这个函数的命名来看,这个函数应该会递归的执行
// 我们先开最后的return语句,发现第一个传进来的参数是0
const next = i => {
// 如果 大于等于钩子函数数组长度, 返回并执行onDone回调,就是tap对象都处理完了
// 跳出递归的条件
if (i >= this.options.taps.length) {
return onDone();
}
// 这个方法就是递归的关键,看见没,逐渐往上遍历
// 注意这里只是定义了方法,并没有执行
const done = () => next(i + 1);
// 传入一个值 如果是false 就执行onDone true 返回一个 ""
// 字面意思,是否跳过done 应该是增加一个跳出递归的条件
const doneBreak = skipDone => {
if (skipDone) return "";
return onDone();
};
// 这里就是处理单个taps对象的关键,传入一个下标,和一系列回调.
return this.callTap(i, {
// 调用的onError 是 (i, err) => onError(err) , 后面这个onError(err)是 () => `throw ${err}`
// 目前 i done doneBreak 都没有用到
onError: error => onError(i, error, done, doneBreak),
// 这里onResult 同步钩子的情况下在外部是没有传进来的,刚才也提到了
// 这里onResult是 undefined
onResult:
onResult &&
(result => {
return onResult(i, result, done, doneBreak);
}),
// 没有onResult 一定要有一个onDone 所以这里就是一个默认的完成回调
// 这里的done 执行的是next(i+1), 也就是迭代的处理完所有的taps
onDone:
!onResult &&
(() => {return done();}),
// rethrowIfPossible 默认是 true 也就是返回后面的
// 因为没有异步函数 firstAsync = -1.
// 所以返回的是 -1 < 0,也就是true, 这个可以判断当前的是否是异步的tap对象
// 这里挺妙的 如果是 false 那么当前的钩子类型就不是sync,可能是promise或者是async
// 具体作用要看callTaps()如何使用这个.
rethrowIfPossible:
rethrowIfPossible && (firstAsync < 0 || i < firstAsync)
});
};
return next(0);
}
callTap()
了.callTap
挺长的,因为他也分了3种类型分别处理,像create()一样.
/** tapIndex 下标
* onError:() => onError(i,err,done,skipdone) ,
* onReslt: undefined
* onDone: () => {return: done()} //开启递归的钥匙
* rethrowIfPossible: false 说明当前的钩子不是sync的.
*/
callTap(tapIndex, { onError, onResult, onDone, rethrowIfPossible }) {
let code = "";
// hasTapCached 是否有tap的缓存, 这个要看看他是怎么做的缓存了
let hasTapCached = false;
// 这里还是拦截器的用法,如果有就执行拦截器的tap函数
for (let i = 0; i < this.options.interceptors.length; i++) {
const interceptor = this.options.interceptors[i];
if (interceptor.tap) {
if (!hasTapCached) {
// 这里getTap返回的是 _taps[0] _taps[1]... 的字符串
// 这里生成的代码就是 `var _tap0 = _taps[0]`
// 注意: _taps 变量我们在 header 那里已经生成了
code += `var _tap${tapIndex} = ${this.getTap(tapIndex)};\n`;
// 可以看到这个变量的作用就是,如果有多个拦截器.这里也只会执行一次.
// 注意这句获取_taps 对象的下标用的是tapIndex,在一次循环中,这个tapIndex不会变
// 就是说如果这里执行多次,就会生成多个重复代码,不稳定,也影响性能.
// 但是你又要判断拦截器有没有tap才可以执行,或许有更好的写法
// 如果你能想到,那么你就是webpack的贡献者了.不过这样写,似乎也没什么不好.
hasTapCached = true;
}
// 这里很明显跟上面的getTap 一样 返回的都是字符串
// 我就直接把这里的code 分析出来了,注意 这里还是在循坏中.
// code += _interceptor[0].tap(_tap0);
// 由于我们的拦截器没有context,所以没传_context进来.
// 可以看到这里是调用拦截器的tap方法然后传入tap0对象的地方
code += `${this.getInterceptor(i)}.tap(${
interceptor.context ? "_context, " : ""
}_tap${tapIndex});\n`;
}
}
// 跑出了循坏
// 这里的getTapFn 返回的也是字符串 `_x[0]`
// callTap用到的这些全部在header() 那里生成了,忘记的回头看一下.
// 这里的code就是: var _fn0 = _x[0]
code += `var _fn${tapIndex} = ${this.getTapFn(tapIndex)};\n`;
const tap = this.options.taps[tapIndex];
// 开始处理tap 对象
switch (tap.type) {
case "sync":
// 全是同步的时候, 这里不执行, 如果有异步函数,那么恭喜,有可能会报错.所以他加了个 try...catch
if (!rethrowIfPossible) {
code += `var _hasError${tapIndex} = false;\n`;
code += "try {\n";
}
// 前面分析了 同步的时候 onResult 是 undefined
// 我们也分析一下如果走这里会怎样
// var _result0 = _fn0(option)
// 可以看到是调用tap 进来的钩子并且接收参数
if (onResult) {
code += `var _result${tapIndex} = _fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined
})});\n`;
} else {
// 所以会走这里
// _fn0(options) 额... 我日 有就接受一下结果
code += `_fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined
})});\n`;
}
// 把 catch 补上,在这个例子中没有
if (!rethrowIfPossible) {
code += "} catch(_err) {\n";
code += `_hasError${tapIndex} = true;\n`;
code += onError("_err");
code += "}\n";
code += `if(!_hasError${tapIndex}) {\n`;
}
// 有onResult 就把结果给传递出去. 目前没有
if (onResult) {
code += onResult(`_result${tapIndex}`);
}
// 有onDone() 就调用他开始递归,还记得上面的next(i+1) 吗?
if (onDone) {
code += onDone();
}
// 这里是不上上面的if的大括号,在这个例子中没有,所以这里也不执行
if (!rethrowIfPossible) {
code += "}\n";
}
// 同步情况下, 这里最终的代码就是
// var _tap0 = _taps[0];
// _interceptors[0].tap(_tap0);
// var _fn0 = _x[0];
// _fn0(options);
// 可以看到,这里会递归下去
// 因为我们tap了4个钩子
// 所以这里会从复4次
// 最终长这样
// var _tap0 = _taps[0];
// _interceptors[0].tap(_tap0);
// var _fn0 = _x[0];
// _fn0(options);
// var _tap1 = _taps[1];
// _interceptors[1].tap(_tap1);
// var _fn1 = _x[1];
// _fn1(options);
// ......
break;
case "async":
let cbCode = "";
if (onResult) cbCode += `(_err${tapIndex}, _result${tapIndex}) => {\n`;
else cbCode += `_err${tapIndex} => {\n`;
cbCode += `if(_err${tapIndex}) {\n`;
cbCode += onError(`_err${tapIndex}`);
cbCode += "} else {\n";
if (onResult) {
cbCode += onResult(`_result${tapIndex}`);
}
if (onDone) {
cbCode += onDone();
}
cbCode += "}\n";
cbCode += "}";
code += `_fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined,
after: cbCode
})});\n`;
break;
case "promise":
code += `var _hasResult${tapIndex} = false;\n`;
code += `var _promise${tapIndex} = _fn${tapIndex}(${this.args({
before: tap.context ? "_context" : undefined
})});\n`;
code += `if (!_promise${tapIndex} || !_promise${tapIndex}.then)\n`;
code += ` throw new Error('Tap function (tapPromise) did not return promise (returned ' + _promise${tapIndex} + ')');\n`;
code += `_promise${tapIndex}.then(_result${tapIndex} => {\n`;
code += `_hasResult${tapIndex} = true;\n`;
if (onResult) {
code += onResult(`_result${tapIndex}`);
}
if (onDone) {
code += onDone();
}
code += `}, _err${tapIndex} => {\n`;
code += `if(_hasResult${tapIndex}) throw _err${tapIndex};\n`;
code += onError(`_err${tapIndex}`);
code += "});\n";
break;
}
return code;
}
好了, 到了这里 我们可以把compile 出来的call 方法输出出来了
"use strict";
function (options) {
var _context;
var _x = this._x;
var _taps = this.taps;
var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
_interceptors[0].call(options);
var _tap0 = _taps[0];
_interceptors[0].tap(_tap0);
var _fn0 = _x[0];
_fn0(options);
var _tap1 = _taps[1];
_interceptors[1].tap(_tap1);
var _fn1 = _x[1];
_fn1(options);
var _tap2 = _taps[2];
_interceptors[2].tap(_tap2);
var _fn2 = _x[2];
_fn2(options);
var _tap3 = _taps[3];
_interceptors[3].tap(_tap3);
var _fn3 = _x[3];
_fn3(options);
}
到了这里可以知道,我们的例子中h1.call()
其实调用的就是这个方法.到此我们可以说是知道了这个库的百分之80了.
不知道大家有没有发现,这个生成的函数的参数列表是从哪里来的呢?往回翻到create()方法里面调用的this.args()
你就会看见,没错就是this._args. 这个东西在哪里初始化呢? 翻一下就知道,这是在Hook.js
这个类里面初始化的,也就是说你h1 = new xxxHook(['options'])
的时候传入的数组有几个值,那么你h1.call({name: 'haha'})
就能传几个值.看教程的时候他说,这里传入的是一个参数名字的字符串列表,那时候我就纳闷,什么鬼,我传入的不是值吗,怎么就变成了参数名称,现在完全掌握....
好了,最简单的SyncHook
已经搞掂,但是一看tapable
内部核心使用的钩子却不是他,而是SyncBailHook
,在教程中我们已经知道,bail
是只要有一个钩子执行完了,并且返回一个值,那么其他的钩子就不执行.我们来看看他是怎么实现的.
从刚才我们弄明白的synchook
,我们知道了他的套路,其实生成的函数的header()
都是一样的,这次我们直接来看看bailhook
实现的content()
方法
content({ onError, onResult, onDone, rethrowIfPossible }) {
return this.callTapsSeries({
onError: (i, err) => onError(err),
// 看回callTapsSeries 就知道这里传入的next 是 done
onResult: (i, result, next) =>
`if(${result} !== undefined) {\n${onResult(
result
)};\n} else {\n${next()}}\n`,
onDone,
rethrowIfPossible
});
}
看出来了哪里不一样吗? 是的bailhook
的 callTapsSeries
传了onResult
属性,我们来看看他这个onResult是啥黑科技
父类传的onResult
默认是 (result) => 'return ${result}'
,那么他这里返回的就是:
// 下面返回的是字符串,
if (xxx !== undefined) {
// 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
return result;
} else {
// next(); 这里返回的是一个字符串(因为要生成字符串代码)
// 我在上面的注释中提到了 next 是 done 就是那个开启递归的门
// 所以如果tap 一直没返回值, 这里就会一直 if...else.. 的嵌套下去
}
回头想想,我们刚刚是不是分析了capTap()
,如果我们传了onResult
会怎样? 如果你还记得就知道,如果有传了onResult
这个回调,他就会接收这个返回值.并且会调用这个回调把result
传出去.
而且还要注意的是,onDone
在callTap()
的时候是处理过的,我在贴出来一次.
onDone:!onResult && (() => {return done();})
也就是说如果我传了onResult
那么这个onDone
就是一个false
.
所以递归的门现在从sync
的onDone
,变到syncBail
的onResult
了
好,现在带着这些变化去看this.capTap()
,你就能推出现在这个 call 函数会变成这样.
"use strict";
function (options) {
var _context;
var _x = this._x;
var _taps = this.taps;
var _interterceptors = this.interceptors;
// 我们只有一个拦截器所以下面的只会生成一个
_interceptors[0].call(options);
var _tap0 = _taps[0];
_interceptors[0].tap(_tap0);
var _fn0 = _x[0];
var _result0 = _fn0(options);
if (_result0 !== undefined) {
// 这里说明,只要有返回值(因为不返回默认是undefined),就会立即return;
return _result0
} else {
var _tap1 = _taps[1];
_interceptors[1].tap(_tap1);
var _fn1 = _x[1];
var _result1 = _fn1(options);
if (_result1 !== undefined) {
return _result1
} else {
var _tap2 = _taps[2];
_interceptors[2].tap(_tap2);
var _fn2 = _x[2];
var _result2 = _fn2(options);
if (_result2 !== undefined) {
return _result2
} else {
var _tap3 = _taps[3];
_interceptors[3].tap(_tap3);
var _fn3 = _x[3];
_fn3(options);
}
}
}
到如今,tapable库 已经删除了 tapable.js文件(可能做了一些整合,更细分了),只留下了钩子文件.但不影响功能,webpack 里的compile
compilation
等一众重要插件,都是基于 tapable库中的这些钩子.
现在我们require('tapable')得到的对象是这样的:
{
SyncHook: function(...){},
SyncBailHook: function(...){},
...
}
到此,关于tapable的大部分我都解剖了一遍,还有其他类型的hook
如果你们愿意,相信你们去研究一下,也能够游刃有余.
那个,写得有些随性,可能会让你们觉得模糊,但是...我真尽力了,这篇改了几遍,历时一个星期...,不懂就在那个评论区问我.我看到会回复的.共勉.
后记:
本来以为会很难,但是越往下深入的时候发现,大神之所以成为大神,不是他的代码写得牛,是他的思维牛,没有看不懂的代码,只有跟不上的思路,要看懂他如何把call 函数组织出来不难,难的是,他居然能想到这样来生成函数,还可以考虑到,拦截器钩子,和context
属性,以及他的 onResult
onDone
回调的判断,架构的设计,等等,一步接一步.先膜拜吧...
路漫漫其修远兮, 吾将上下而求索.
查看原文上一遍博文中,我们谈到了tapable的用法,现在我们来深入一下tap究竟是怎么运行的, 怎么处理,控制 tap 进去的钩子函数,拦截器又是怎么运行的.
赞 15 收藏 6 评论 6
李孤雏 发布了文章 · 2018-12-18
搜索引擎搜索tapable中文文档,你会看见各种翻译,点进去一看,确实是官方的文档翻译过来的,但是webpack的文档确实还有很多需要改进的地方,既然是开源的为什么不去github上的tapable库看呢,一看,确实,比webpack文档上的描述得清楚得多.
tapable 是一个类似于nodejs 的EventEmitter 的库, 主要是控制钩子函数的发布与订阅,控制着webpack的插件系.webpack的本质就是一系列的插件运行.
Tapable库 提供了很多的钩子类, 这些类可以为插件创建钩子
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
npm install --save tapable
所有的钩子构造函数,都接受一个可选的参数,(这个参数最好是数组,不是tapable内部也把他变成数组),这是一个参数的字符串名字列表
const hook = new SyncHook(["arg1", "arg2", "arg3"]);
最好的实践就是把所有的钩子暴露在一个类的hooks属性里面:
class Car {
constructor() {
this.hooks = {
accelerate: new SyncHook(["newSpeed"]),
brake: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
}
/* ... */
}
其他开发者现在可以这样用这些钩子
const myCar = new Car();
// Use the tap method to add a consument
// 使用tap 方法添加一个消费者,(生产者消费者模式)
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());
这需要你传一个名字去标记这个插件:
你可以接收参数
myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));
在同步钩子中, tap 是唯一的绑定方法,异步钩子通常支持异步插件
// promise: 绑定promise钩子的API
myCar.hooks.calculateRoutes.tapPromise("GoogleMapsPlugin", (source, target, routesList) => {
// return a promise
return google.maps.findRoute(source, target).then(route => {
routesList.add(route);
});
});
// tapAsync:绑定异步钩子的API
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
bing.findRoute(source, target, (err, route) => {
if(err) return callback(err);
routesList.add(route);
// call the callback
callback();
});
});
// You can still use sync plugins
// tap: 绑定同步钩子的API
myCar.hooks.calculateRoutes.tap("CachedRoutesPlugin", (source, target, routesList) => {
const cachedRoute = cache.get(source, target);
if(cachedRoute)
routesList.add(cachedRoute);
})
类需要调用被声明的那些钩子
class Car {
/* ... */
setSpeed(newSpeed) {
// call(xx) 传参调用同步钩子的API
this.hooks.accelerate.call(newSpeed);
}
useNavigationSystemPromise(source, target) {
const routesList = new List();
// 调用promise钩子(钩子返回一个promise)的API
return this.hooks.calculateRoutes.promise(source, target, routesList).then(() => {
return routesList.getRoutes();
});
}
useNavigationSystemAsync(source, target, callback) {
const routesList = new List();
// 调用异步钩子API
this.hooks.calculateRoutes.callAsync(source, target, routesList, err => {
if(err) return callback(err);
callback(null, routesList.getRoutes());
});
}
}
tapable会用最有效率的方式去编译(构建)一个运行你的插件的方法,他生成的代码依赖于一下几点:
这些确定了尽可能快的执行.
每一个钩子都可以tap 一个或者多个函数, 他们如何运行,取决于他们的钩子类型
此外,钩子可以是同步的,也可以是异步的,Sync, AsyncSeries 和 AsyncParallel ,从名字就可以看出,哪些是可以绑定异步函数的
myHook.tap()
, myHook.tapAsync()
和 myHook.tapPromise()
.).他会按顺序的调用每个方法.所有钩子都提供额外的拦截器API
// 注册一个拦截器
myCar.hooks.calculateRoutes.intercept({
call: (source, target, routesList) => {
console.log("Starting to calculate routes");
},
register: (tapInfo) => {
// tapInfo = { type: "promise", name: "GoogleMapsPlugin", fn: ... }
console.log(`${tapInfo.name} is doing its job`);
return tapInfo; // may return a new tapInfo object
}
})
call:(...args) => void
当你的钩子触发之前,(就是call()之前),就会触发这个函数,你可以访问钩子的参数.多个钩子执行一次
tap: (tap: Tap) => void
每个钩子执行之前(多个钩子执行多个),就会触发这个函数
loop:(...args) => void
这个会为你的每一个循环钩子(LoopHook, 就是类型到Loop的)触发,具体什么时候没说
register:(tap: Tap) => Tap | undefined
每添加一个Tap
都会触发 你interceptor上的register,你下一个拦截器的register 函数得到的参数 取决于你上一个register返回的值,所以你最好返回一个 tap 钩子.
插件和拦截器都可以选择加入一个可选的 context对象, 这个可以被用于传递随意的值到队列中的插件和拦截器.
myCar.hooks.accelerate.intercept({
context: true,
tap: (context, tapInfo) => {
// tapInfo = { type: "sync", name: "NoisePlugin", fn: ... }
console.log(`${tapInfo.name} is doing it's job`);
// `context` starts as an empty object if at least one plugin uses `context: true`.
// 如果最少有一个插件使用 `context` 那么context 一开始是一个空的对象
// If no plugins use `context: true`, then `context` is undefined
// 如过tap进去的插件没有使用`context` 的 那么内部的`context` 一开始就是undefined
if (context) {
// Arbitrary properties can be added to `context`, which plugins can then access.
// 任意属性都可以添加到`context`, 插件可以访问到这些属性
context.hasMuffler = true;
}
}
});
myCar.hooks.accelerate.tap({
name: "NoisePlugin",
context: true
}, (context, newSpeed) => {
if (context && context.hasMuffler) {
console.log("Silence...");
} else {
console.log("Vroom!");
}
});
一个 HookMap是一个Hooks映射的帮助类
const keyedHook = new HookMap(key => new SyncHook(["arg"]))
keyedHook.tap("some-key", "MyPlugin", (arg) => { /* ... */ });
keyedHook.tapAsync("some-key", "MyPlugin", (arg, callback) => { /* ... */ });
keyedHook.tapPromise("some-key", "MyPlugin", (arg) => { /* ... */ });
const hook = keyedHook.get("some-key");
if(hook !== undefined) {
hook.callAsync("arg", err => { /* ... */ });
}
Public(权限公开的):
interface Hook {
tap: (name: string | Tap, fn: (context?, ...args) => Result) => void,
tapAsync: (name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void,
tapPromise: (name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void,
intercept: (interceptor: HookInterceptor) => void
}
interface HookInterceptor {
call: (context?, ...args) => void,
loop: (context?, ...args) => void,
tap: (context?, tap: Tap) => void,
register: (tap: Tap) => Tap,
context: boolean
}
interface HookMap {
for: (key: any) => Hook,
tap: (key: any, name: string | Tap, fn: (context?, ...args) => Result) => void,
tapAsync: (key: any, name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void,
tapPromise: (key: any, name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void,
intercept: (interceptor: HookMapInterceptor) => void
}
interface HookMapInterceptor {
factory: (key: any, hook: Hook) => Hook
}
interface Tap {
name: string,
type: string
fn: Function,
stage: number,
context: boolean
}
Protected(保护的权限),只用于类包含的(里面的)钩子
interface Hook {
isUsed: () => boolean,
call: (...args) => Result,
promise: (...args) => Promise<Result>,
callAsync: (...args, callback: (err, result: Result) => void) => void,
}
interface HookMap {
get: (key: any) => Hook | undefined,
for: (key: any) => Hook
}
把其他的Hook 重定向(转化)成为一个 MultiHook
const { MultiHook } = require("tapable");
this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);
OK 所有的内容我都已翻译完成.
其中有很多不是直译,这样写下来感觉就是按照原文的脉络重新写了一遍....,应该能更清楚明白,要不是怕丢脸我就给个原创了,哈哈.
之后, 我还会写一篇完整的原创解析,直击源码,搞定tapable, 完全了解webpack插件系统(webpack本来就是一个插件的事件流), 好久没写原创了. 我自己也很期待.
查看原文搜索引擎搜索tapable中文文档,你会看见各种翻译,点进去一看,确实是官方的文档翻译过来的,但是webpack的文档确实还有很多需要改进的地方,既然是开源的为什么不去github上的tapable库看呢,一看,确实,比webpack文档上的描述得清楚得多.
赞 40 收藏 22 评论 3
李孤雏 评论了文章 · 2018-12-05
在很久之前我就说过同样的话,表达过我觉得做为前端工程师而言设计素养的重要性,今天我想聊天为什么我有这种观点
实际上生活中设计是中无处不在的,大到建筑工程、工业设计,小到网页设计、产品设计。我认为设计的本质就是 理解你(或者你的用户)内心想法的一个过程,在完成了一个物件的物理功能后,你需要考虑它的适用场景及多数用户的实用需求
我在北京呆了很多年,你要问我对北京的印象是什么颜色,我会毫不犹豫的告诉你「蓝色」。有的人会认为是雾霾的灰色、有的人会认为是天安门的红色。为什么我的印象是蓝色呢,因为在北京无论你去哪儿都会选择公共交通,所有的交通标识牌都是蓝背景加白前景色,环路上的路标、地铁标、普通道路的路标,到处都会有蓝色的标识牌。使用蓝色的好处在于 标识性强,尤其对我这种视觉异常的人特别友好。但是却缺乏美感,因为要照顾大多数人的体验
我也常去西安,你要问我对西安的印象是什么颜色,我也会告诉你是「暗红」。西安的地铁标识牌就是这种暗红,我也不太确定这种颜色准确的叫法,赤红或者朱红?总之这种颜色和西安这个城市的调性很搭。无论是古城门上的各种架梁、门柱、瓦石的着色,还是现代的地铁标识颜色,到处都有暗红色的设计。西安地铁标的设计虽然说是和整个城市的气质具有一致性,但是 识别性很差,尤其城市里面绿化比较好,树木多的时候绿色和这种红色标识交错在一起是很难分辨的
从这个例子中我们可以了解到,其实设计并不是那种只存在于理论或者艺术世界里面的东西,大多数的设计都源于生活。都表达了人们对于生活的思考与理解
狭义点讲前端工程师(程序员)们的日常是程序设计或者软件设计。有一个关于用户体验的真相是:大多数用户当他们不喜欢你的产品时,他们会 直接离开并放弃。用户的选择是正向的,他会因为你的软件好用选择,但 并不一定 会因为你的软件不好而批评反馈。所以我一直认为很多所谓的为了提高用户体验的调查问卷并没有什么作用。相反的很多反馈都是没有经过深思熟虑,或者是很个人的需求,这反倒会影我们的判断
所以说代码设计的的好不好,交互是否流畅,体验是否极致。这个门槛的最后一步就在前端
当然后端也很重要,后端的重要性是我们在这聊设计聊体验的前提。「仓廪实而知礼节」,很多前端在知乎提问类似「Node.JS 和 Java 相比…」的话题的时候却从来没想过这个问题,不过这个话题就不细聊了
前端需要关注设计,原因有二:
一、离用户更近。这个毫无疑问,前端在整个软件的系统栈里面是最顶部一个元素,他们写出来的代码第一用户是自己,一个功能好不好用前端会有第一知觉。同时这个也依赖于工程师对于设计体验的素养。很多东西是没法区分 逻辑上的好坏,同时有些东西应该是 不言而喻 的:
页面的链接到底应该在当前面页打开还是新页签
这就是一个典型的没法从逻辑上区分好坏的问题。当然所有页面都新窗口打开肯定是不对的。我认为 <base target="_blank"/>
在任何时时候都不应该被使用。看看自己每天使用的浏览器 tab 页的个数就明白了。实际使用的时候要根据用户的场景、喜好、链接去向内容、技术实现等各方面因素综合考虑,一刀切 的做法绝对是错误的
弹出层、hover 提示 应该是尽量少的使用
这就是不言而喻的,因为在 PC 端用户的鼠标是最常用的输入设备。鼠标的 mouseover
事件会产生很多误操作,浮层的显示这会骚扰用户的视觉。当然技术上我们可以通给 mouseover
事件添加延迟的方式来避免误操作的机率,但是我认为这是一种 打补丁 的解决方法,因为当一个提示信息足够重要的时候,任何延迟都是错误的,更何况打补丁还有副作用
最近刚好发现一个笔记类应用「www.notion.so」,初次使用的时候感觉真的非常棒。设计精美、交互流畅、动画细腻,好的产品不就应该是这样的么
但是当你深入使用的时候你会发现这个页面上充满了各个的 hover 效果,提示信息、状态切换等。它几乎在所有的图标上都加了 功能提示或快捷键的 hover 提示,这个在我看来就非常的骚扰用户。就比如左侧菜单的收起按钮使用了左箭头的图标「<」,这种图标就是不言而喻的,不需要再用 tooltip 来提示
二、审美需求。这一点其实上是所有人都需要关注的,很多程序员在使用 macOS 一段时间后就再也没法忍受 windows 的界面了。单从审美诉求这一项讲,macOS 体现出来的简洁、专注是 windows 系统没有的。结合上面的问题可以思考下:为什么 macOS 系统的鼠标 hover 的交互很少?我觉得应该是操作系统给用户带来的那种稳定、可靠的感觉。当你专注的做一件事情的时候最好不要有任何形式的打扰,像系统通知、气泡提醒什么的应该完全被禁止
很多人会认为程序员不需要审美,因为他们实现功能就行了。事实上甚至很多程序员也可能是这么想的。但是他们并没有意识到一点,即使是那种特别不关心审美的人也有基本审美的需求,或者说一个长像其丑无比的人也有基本审美的需求,爱美之心人皆有之。只是程序员这类群体写代码太久就会进入特别专注的状态,只在乎代码,忘了其它一些同样有价值的东西
我想表达的意思并不是说这样不好,而是我认为在专注技术的前提下了解一些设计方面的东西会让我们理解别人(用户)的想法,这其实也是一种与外界的沟通形式,也能弥补程序员天生的弱点
很多对设计一知半解的人会说一句别人经过实践总结出来的真理:
Rules are meant to be broken — 规则就是用来被打破的
在《写给大家看的设计书》中就讲过这个问题,我是比较同意作者的观点,即:打破规则的前提应该是你足够了解规则是什么,意味着什么
同时书中开篇分享了另外一个观点,我也很赞同:
当你能叫出一个东西的名字后,这个东西就无处不在了
这个估计很多人都会有这种体验,某一天某个人说了一个你以前从来没听过的词儿,然后你觉得这个词很新鲜,接着在后来一段时间内这个词就会不段的出现在你的周围
作者的意思是,其实就是设计在很多人眼中遙不可及并不是因为它很高深,而是你没听过、没见过一些设计规则
后面的东西就不聊了,读者有兴趣可以读下原著,书中提到的几个原则至今都在我的脑海里:亲密、对齐、重复、对比
欢迎关注微信公众号「肆零玖陆」— 以程序员的视角谈天说地
实际上生活中设计是中无处不在的,大到建筑工程、工业设计,小到网页设计、产品设计。我认为设计的本质就是 理解你(或者你的用户)内心想法的一个过程,在完成了一个物件的物理功能后,你需要考虑它的适用场景及多数用户的实用需求
李孤雏 发布了文章 · 2018-12-04
这是第三篇,也是目前为止的最后一篇了.
在这个系列的第一部分我们学习了在webpack,和webpack的github 组织(其实就是介绍webpack各个基础库的用途,感兴趣的可以去看一下链接在这里),在第二部分,我们介绍了Tapable,学习了一个类似于nodejs 的 EventEmitle的只有2百多行的代码的库,还知道了他掌控着webpack 的整个插件系统.
除此之外,我们知道了webpack 如何去创建tapable 实例(继承Tapable的类),和webpack 如何去注册他们,和执行他们的功能,最后我们学习了每一个在webpack中的tapable实例的功能.
在这篇文章中,我们准备结合我们所学到的东西和webpack如何构建依赖图的高级解释连在一起
依赖图是webpack中的一个关键架构,我们相信,只要我们知道了他是如何工作的,就能给我们带来更远的眼界.(就是看webpack 看得更透彻了)
这里作者贴了一个youtube的链接, 是作者在一次演讲中对webpack 一次解析, 他建议配套观赏,没字幕,英文字幕都没,而且还很长有一个半小时,有兴趣的可以去看一下.
设置我们已经拥有的webpack配置(所谓的编译选项), 当webpack运行的时候我们遇到的第一个Tapable实例就是Compiler
. 因为他只负责触发run
, failed
, done
这些高级事件,所以他是一个中央调度器, 这个编译器(Compiler) 始终会返回一个Compilation
, 和其他的一些重要的tapable实例,例如NormalModuleFactory
, ContextModuleFactory
当Compiler
实例化了编译所需的插件和对象之后, 他会返回一个nwe Cpmpilation
在Compilation 之后 (第二个Tapable实例)
我们把你应用的依赖图描述成为Compilation
, 就好像把一个人描述成一个对象一样,我们必须要在某处有一个跟节点,然后分支出其他的节点
我们正在描述的就是你的配置的入口属性,
即使我们提供了入口点路径,webpack 还是需要确认那个路径存不存在,下面我们将会开启一组递归操作
任何时候都可以提供一个原请求(模块的路径),在这个例子中,就是入口点,webpack会首先发送这些路径信息给Resolve实例去解析得到入口文件.Resolve 实例会用 增强的nodejs 正则模版去确定该路径是否存在该模块,然后返回一个关于Resolve 模块的额外的信息, 这个信息包含文件系统统计信息,绝对路径,和Resolve模块的唯一ID标识
NormalModule
,在模块源存储之后,Parser
会分析这个模块,此外,他还会通过被称作loaders
(loader 概念不懂得可以查看官方文档)一系列的转换去发送Module
,一个loader链在最后都会返回一个JavaScript代码,因此,Parser
现在可以开始解析源码并且声称AST(抽象语法树)了Parse
遍历AST 并且遇上require(foo)
这样的信息的时候,那么这个信息就会存储到Dependeny
实例,并且把他跟原来的模块关联起来(链表的概念)译者注: webpack 大概的流程说得挺清楚明白的,就下来就是要看各位骚操作的时候了
查看原文在这个系列的第一部分我们学习了在webpack,和webpack的github 组织(其实就是介绍webpack各个基础库的用途,感兴趣的可以去看一下链接在这里),在第二部分,我们介绍了Tapable,学习了一个类似于nodejs 的 EventEmitle的只有2百多行的代码的库,还知道了他掌控着webpack 的...
赞 1 收藏 0 评论 0
查看全部 个人动态 →
(゚∀゚ )
暂时没有
注册于 2018-05-29
个人主页被 1.3k 人浏览
推荐关注