Vue.js 源码分析(二十八) 高级应用 transition组件 详解

时间:2023-03-08 19:59:48

transition组件可以给任何元素和组件添加进入/离开过渡,但只能给单个组件实行过渡效果(多个元素可以用transition-group组件,下一节再讲),调用该内置组件时,可以传入如下特性:

name         用于自动生成CSS过渡类名        例如:name:'fade'将自动拓展为.fade-enter,.fade-enter-active等
    appear      是否在初始渲染时使用过渡         默认为false
    css            是否使用 CSS 过渡类。             默认为 true。如果设置为 false,将只通过组件事件触发注册的 JavaScript 钩子。
    mode        控制离开/进入的过渡时间序列    可设为"out-in"或"in-out";默认同时生效
    type          指定过渡事件类型                      可设为transition或animation,用于侦听过渡何时结束;可以不设置,Vue内部会自动检测出持续时间长的为过渡事件类型
    duration    定制进入和移出的持续时间        以后用到再看

type表示transition对应的css过渡类里的动画样式既可以用transition也可以用animation来设置动画(可以同时使用),然后我们可以用指定,Vue内部会自动判断出来

除了以上特性,我们还可以设置如下特性,用于指定过渡的样式:

appear-class             初次渲染时的起始状态    ;如果不存在则等于enter-class属性                 这三个属性得设置了appear为true才生效
    appear-to-class         初次渲染时的结束状态    如果不存在则等于enter-to-class    属性
    appear-active-class   初次渲染时的过渡           如果不存在则等于enter-active-class属性
    enter-class                进入过渡时的起始状态  
    enter-to-class            进入过渡时的结束状态 
    enter-active-class     进入过渡时的过渡          
    leave-class               离开过渡时的起始状态    
    leave-to-class          离开过渡时的结束状态    
    leave-active-class    离开过渡时的过渡

对于后面六个class,内部会根据name拼凑出对应的class来,例如一个transition的name="fade",拼凑出来的class名默认分别为:fade-enter、fade-enter-to、fade-enter-active、fade-leave、fade-leave-to、fade-leave-active

除此之外还可以在transition中绑定自定义事件,所有的自定义事件如下

before-appear          初次渲染,过渡前的事件                         未指定则等于before-enter事件    
    appear                     初次渲染开始时的事件                             未指定则等于enter事件 
    after-appear             初次渲染,过渡结束后的事件                  未指定则等于enter-cancelled事件    
    appear-cancelled     初次渲染未完成又触发隐藏条件而重新渲染时的事件,未指定则等于enter-cancelled事件    
    before-enter             进入过渡前的事件
    enter                        进入过渡时的事件                            
    after-enter               进入过渡结束后的事件
    enter-cancelled       进入过渡未完成又触发隐藏条件而重新渲染时的事件    
    before-leave           离开过渡前的事件
    leave                      离开时的事件                                   
    after-leave              离开后的事件
    leave-cancelled      进入过渡未完成又触发隐藏条件而重新渲染时的事件

transition相关的所有属性应该都列出来了(应该比官网还多吧,我是从源码里找到的),我们举一个例子,如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script>
</head>
<style>
.fade-enter,.fade-leave-to{background: #f00;transform:translateY(20px);} /*.fade-enter和.fade-leave-to一般写在一起,当然也可以分开*/
.fade-enter-active,.fade-leave-to{transition:all 1s linear 500ms;}
</style>
<body>
<div id="app">
<button @click="show=!show">按钮</button>
<transition name="fade" :appear="true" @before-enter="beforeenter" @enter="enter" @after-enter="afterenter" @before-leave="beforeleave" @leave="leave" @after-leave="afterleave">
<p v-if="show">你好</p>
</transition>
</div>
<script>
Vue.config.productionTip=false;
Vue.config.devtools=false;
var app = new Vue({
el:"#app",
data:{
show:true
},
methods:{
beforeenter(){console.log('进入过渡前的事件')},
enter(){console.log('进入过渡开始的事件')},
afterenter(){console.log('进入过渡结束的事件')},
beforeleave(){console.log('离开过渡前的事件')},
leave(){console.log('离开过渡开始的事件')},
afterleave(){console.log('离开过渡结束的事件')}
}
})
</script>
</body>
</html>

我们调用transition组件时设置了appear特性为true,这样页面加载时动画就开始了,如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

控制台输出如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

文字从透明到渐显,同时位移也发生了变化,我们点击按钮时又会触发隐藏,继续点击,又会显示,这是因为我们在transition的子节点里使用了v-show指令。

对于transition组件来说,在下列情形中,可以给任何元素和组件添加进入/离开过渡:

条件渲染 (使用 v-if)
    条件展示 (使用 v-show)
    动态组件
    组件根节点

用原生DOM模拟transition组件


Vue内部是通过修改transition子节点的class名来实现动画效果的,我们用原生DOM实现一下这个效果,如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<style>
.trans{transition:all 2s linear;}
.start{transform:translatex(100px);opacity: 0;}
</style>
<body>
<div id="con">
<button name="show">显式</button>
<button name="hide">隐藏</button>
</div>
<p id="p">Hello Vue!</p>
<script>
var p = document.getElementsByTagName('p')[0];
document.getElementById('con').addEventListener('click',function(event){
switch(event.target.name){
case "show":
p.style.display="block";
p.classList.add('trans');
p.classList.remove('start')
break;
case "hide":
p.classList.add('trans')
p.classList.add('start')
break;
}
})
</script>
</body>
</html>

渲染的页面如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

我们点击隐藏按钮后,Hello Vue!就逐渐隐藏了,然后我们查看DOM,如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

这个DOM元素还是存在的,只是opacity这个透明度的属性为0,Vue内部的transition隐藏后是一个注释节点,这是怎么实现的,我们能不能也实现出来,当然可以。

Vue内部通过window.getComputedStyle()这个API接口获取到了transition或animation的结束时间,然后通过绑定transitionend或animationend事件(对应不同的动画结束事件)执行一个回调函数,该回调函数会将DOM节点设置为一个注释节点(隐藏节点的情况下)

我们继续改一下代码,如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
</head>
<style>
.trans{transition:all 2s linear;}
.start{transform:translatex(100px);opacity: 0;}
</style>
<body>
<div id="con">
<button name="show">显式</button>
<button name="hide">隐藏</button>
</div>
<p id="p">Hello Vue!</p>
<script>
var p = document.getElementsByTagName('p')[0],
tid = null,
pDom = null,
CommentDom = document.createComment("");
document.getElementById('con').addEventListener('click',function(event){
switch(event.target.name){
case "show":
CommentDom.parentNode.replaceChild(p,CommentDom)
setTimeout(function(){p.classList.remove('start')},10)
ModifyClass(1)
break;
case "hide":
p.classList.add('trans')
p.classList.add('start')
ModifyClass(0)
break;
}
}) function ModifyClass(n){ //s=1:显式过程 s=0:隐藏过程
var styles = window.getComputedStyle(p);
var transitionDelays = styles['transitionDelay'].split(', '); //transition的延迟时间 ;比如:["0.5s"]
var transitionDurations = styles['transitionDuration'].split(', '); //transition的动画持续时间 ;比如:"1s"
var transitionTimeout = getTimeout(transitionDelays, transitionDurations); //transition的获取动画结束的时间,单位ms,比如:1500
tid && clearTimeout(tid);
tid=setTimeout(function(){
if(n){ //如果是显式
p.classList.remove('trans')
p.removeAttribute('class');
}else{ //如果是隐藏
p.parentNode.replaceChild(CommentDom,p);
}
},transitionTimeout)
} function getTimeout(delays, durations) { //从Vue源码里拷贝出来的代码的,获取动画完成的总时间,返回ms格式
while (delays.length < durations.length) {
delays = delays.concat(delays);
}
return Math.max.apply(null, durations.map(function (d, i) {
return toMs(d) + toMs(delays[i])
}))
}
function toMs(s) {
return Number(s.slice(0, -1)) * 1000
}
</script> </body>
</html>

这样当动画结束后改DOM就真的隐藏了,变为了一个注释节点,如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

当再次点击时,就会显式出来,如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

完美,这里遇到个问题,就是当显式的时候直接设置class不会有动画,应该是和重绘有关的吧m所以用了一个setTImeout()来实现。

Vue也就是把这些原生DOM操作进行了封装,我们现在来看Vue的源码

源码分析


transition是Vue的内置组件,在执行initGlobalAPI()时extend保存到Vue.options.component(第5052行),我们可以打印看看,如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

Transition组件的格式为:

var Transition = {    //第8012行  transition组件的定义
name: 'transition',
props: transitionProps,
abstract: true, render: function render (h) {
/**/
}
}

也就是说transition组件定义了自己的render函数。

以上面的第一个例子为例,执行到transition组件时会执行到它的render函数,如下:

  render: function render (h) {         //第8217行  transition组件的render函数,并没有template模板,初始化或更新都会执行到这里
var this$1 = this; var children = this.$slots.default;
if (!children) {
return
} // filter out text nodes (possible whitespaces)
children = children.filter(function (c) { return c.tag || isAsyncPlaceholder(c); });
/* istanbul ignore if */
if (!children.length) { //获取子节点
return //如果没有子节点,则直接返回
} // warn multiple elements
if ("development" !== 'production' && children.length > 1) { //如果过滤掉空白节点后,children还是不存在,则直接返回
warn(
'<transition> can only be used on a single element. Use ' +
'<transition-group> for lists.',
this.$parent
);
} var mode = this.mode; //获取模式 // warn invalid mode
if ("development" !== 'production' &&
mode && mode !== 'in-out' && mode !== 'out-in' //检查mode是否规范只能是in-out或out-in
) {
warn(
'invalid <transition> mode: ' + mode,
this.$parent
);
} var rawChild = children[0]; //获取所有子节点 // if this is a component root node and the component's
// parent container node also has transition, skip.
if (hasParentTransition(this.$vnode)) { //如果当前的transition是根组件,且调用该组件的时候外层又套了一个transition
return rawChild //则直接返回rawChild
} // apply transition data to child
// use getRealChild() to ignore abstract components e.g. keep-alive
var child = getRealChild(rawChild);
/* istanbul ignore if */
if (!child) {
return rawChild
} if (this._leaving) {
return placeholder(h, rawChild)
} // ensure a key that is unique to the vnode type and to this transition
// component instance. This key will be used to remove pending leaving nodes
// during entering.
var id = "__transition-" + (this._uid) + "-"; //拼凑key,比如:__transition-1 ;this._uid是transition组件实例的_uid,在_init初始化时定义的
child.key = child.key == null
? child.isComment
? id + 'comment'
: id + child.tag
: isPrimitive(child.key)
? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
: child.key; var data = (child.data || (child.data = {})).transition = extractTransitionData(this); //获取组件上的props和自定义事件,保存到child.data.transition里
var oldRawChild = this._vnode;
var oldChild = getRealChild(oldRawChild); // mark v-show
// so that the transition module can hand over the control to the directive
if (child.data.directives && child.data.directives.some(function (d) { return d.name === 'show'; })) { //如果child带有一个v-show指令
child.data.show = true; //则给child.data新增一个show属性,值为true
} if (
oldChild &&
oldChild.data &&
!isSameChild(child, oldChild) &&
!isAsyncPlaceholder(oldChild) &&
// #6687 component root is a comment node
!(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment) //这里是更新组件,且子组件改变之后的逻辑
) {
// replace old child transition data with fresh one
// important for dynamic transitions!
var oldData = oldChild.data.transition = extend({}, data);
// handle transition mode
if (mode === 'out-in') {
// return placeholder node and queue update when leave finishes
this._leaving = true;
mergeVNodeHook(oldData, 'afterLeave', function () {
this$1._leaving = false;
this$1.$forceUpdate();
});
return placeholder(h, rawChild)
} else if (mode === 'in-out') {
if (isAsyncPlaceholder(child)) {
return oldRawChild
}
var delayedLeave;
var performLeave = function () { delayedLeave(); };
mergeVNodeHook(data, 'afterEnter', performLeave);
mergeVNodeHook(data, 'enterCancelled', performLeave);
mergeVNodeHook(oldData, 'delayLeave', function (leave) { delayedLeave = leave; });
}
} return rawChild //返回DOM节点
}

extractTransitionData()可以获取transition组件上的特性等,如下:

function extractTransitionData (comp) {   //第8176行  提取在transition组件上定义的data
var data = {};
var options = comp.$options; //获取comp组件的$options字段
// props
for (var key in options.propsData) { //获取propsData
data[key] = comp[key]; //并保存到data里面 ,例如:{appear: true,name: "fade"}
}
// events.
// extract listeners and pass them directly to the transition methods
var listeners = options._parentListeners; //获取在transition组件上定义的自定义事件
for (var key$1 in listeners) { //遍历自定义事件
data[camelize(key$1)] = listeners[key$1]; //也保存到data上面
}
return data
}

例子里的transition组件执行到返回的值如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

也就是说transition返回的是子节点VNode,它只是在子节点VNode的data属性上增加了transition组件相关的信息

对于v-show指令来说,初次绑定时会执行bind函数(可以看https://www.cnblogs.com/greatdesert/p/11157771.html),如下:

var show = {        //第8082行
bind: function bind (el, ref, vnode) { //初次绑定时执行
var value = ref.value; vnode = locateNode(vnode);
var transition$$1 = vnode.data && vnode.data.transition; //尝试获取transition,如果v-show绑定的标签外层套了一个transition则会把信息保存到该对象里
var originalDisplay = el.__vOriginalDisplay =
el.style.display === 'none' ? '' : el.style.display; //保存最初的display属性
if (value && transition$$1) { //如果transition$$1存在的话
vnode.data.show = true;
enter(vnode, function () { //执行enter函数,参数2是个函数,是动画结束的回掉函数
el.style.display = originalDisplay;
});
} else {
el.style.display = value ? originalDisplay : 'none';
}
},

最后会执行enter函数,enter函数也就是动画的入口函数,比较长,如下:

function enter (vnode, toggleDisplay) {             //第7599行  进入动画的回调函数
var el = vnode.elm; // call leave callback now
if (isDef(el._leaveCb)) { //如果el._leaveCb存在,则执行它,离开过渡未执行完时如果重新触发了进入过渡,则执行到这里
el._leaveCb.cancelled = true;
el._leaveCb();
} var data = resolveTransition(vnode.data.transition); //调用resolveTransition解析vnode.data.transition里的css属性
if (isUndef(data)) {
return
} /* istanbul ignore if */
if (isDef(el._enterCb) || el.nodeType !== 1) {
return
} var css = data.css; //是否使用 CSS 过渡类
var type = data.type; //过滤类型,可以是transition或animation 可以为空,Vue内部会自动检测
var enterClass = data.enterClass; //获取进入过渡是的起始、结束和过渡时的状态对应的class
var enterToClass = data.enterToClass;
var enterActiveClass = data.enterActiveClass;
var appearClass = data.appearClass; //获取初次渲染时的过渡,分别是起始、结束和过渡时的状态对应的class
var appearToClass = data.appearToClass;
var appearActiveClass = data.appearActiveClass;
var beforeEnter = data.beforeEnter; //进入过渡前的事件,以下都是相关事件
var enter = data.enter;
var afterEnter = data.afterEnter;
var enterCancelled = data.enterCancelled;
var beforeAppear = data.beforeAppear;
var appear = data.appear;
var afterAppear = data.afterAppear;
var appearCancelled = data.appearCancelled;
var duration = data.duration; // activeInstance will always be the <transition> component managing this
// transition. One edge case to check is when the <transition> is placed
// as the root node of a child component. In that case we need to check
// <transition>'s parent for appear check.
var context = activeInstance; //当前transition组件的Vue实例vm
var transitionNode = activeInstance.$vnode; //占位符VNode
while (transitionNode && transitionNode.parent) { //如果transitoin组件是作为根节点的
transitionNode = transitionNode.parent; //则修正transitionNode为它的parent
context = transitionNode.context; //修正context为对应的parent的context
} var isAppear = !context._isMounted || !vnode.isRootInsert; //当前是否还未初始化 如果transition组件还没有挂载,则设置isAppear为true if (isAppear && !appear && appear !== '') { //如果appear为false(当前是初始化),且appear为false(即初始渲染时不使用过渡),或不存在
return //则直接返回,不做处理
} var startClass = isAppear && appearClass //进入过渡的起始状态
? appearClass
: enterClass;
var activeClass = isAppear && appearActiveClass //进入过渡时的状态
? appearActiveClass
: enterActiveClass;
var toClass = isAppear && appearToClass //进入过渡的结束状态
? appearToClass
: enterToClass; var beforeEnterHook = isAppear
? (beforeAppear || beforeEnter)
: beforeEnter;
var enterHook = isAppear
? (typeof appear === 'function' ? appear : enter)
: enter;
var afterEnterHook = isAppear
? (afterAppear || afterEnter)
: afterEnter;
var enterCancelledHook = isAppear
? (appearCancelled || enterCancelled)
: enterCancelled; var explicitEnterDuration = toNumber(
isObject(duration)
? duration.enter
: duration
); if ("development" !== 'production' && explicitEnterDuration != null) {
checkDuration(explicitEnterDuration, 'enter', vnode);
} var expectsCSS = css !== false && !isIE9; //是否使用 CSS 过渡类 IE9是不支持的
var userWantsControl = getHookArgumentsLength(enterHook); var cb = el._enterCb = once(function () { //完成后的回调函数
if (expectsCSS) {
removeTransitionClass(el, toClass);
removeTransitionClass(el, activeClass);
}
if (cb.cancelled) {
if (expectsCSS) {
removeTransitionClass(el, startClass);
}
enterCancelledHook && enterCancelledHook(el);
} else {
afterEnterHook && afterEnterHook(el);
}
el._enterCb = null;
}); if (!vnode.data.show) {
// remove pending leave element on enter by injecting an insert hook
mergeVNodeHook(vnode, 'insert', function () {
var parent = el.parentNode;
var pendingNode = parent && parent._pending && parent._pending[vnode.key];
if (pendingNode &&
pendingNode.tag === vnode.tag &&
pendingNode.elm._leaveCb
) {
pendingNode.elm._leaveCb();
}
enterHook && enterHook(el, cb);
});
} // start enter transition
beforeEnterHook && beforeEnterHook(el); //如果定义了beforeEnterHook钩子函数,则执行它,例子里的beforeenter会执行这里,输出:进入过渡前的事件
if (expectsCSS) { //如果expectsCSS为true
addTransitionClass(el, startClass); //给el元素新增一个class,名为startClass
addTransitionClass(el, activeClass); //给el元素新增一个class,名为activeClass
nextFrame(function () { //下次浏览器重绘时
removeTransitionClass(el, startClass); //移除startClass这个class ;因为有设置了activeClass,所以此时就会开始执行动画了
if (!cb.cancelled) { //如果cb.cancelled为空
addTransitionClass(el, toClass); //添加toClass这个class
if (!userWantsControl) {
if (isValidDuration(explicitEnterDuration)) { //如果用户自定义了动画时间
setTimeout(cb, explicitEnterDuration);
} else {
whenTransitionEnds(el, type, cb); //否则执行默认的whenTransitionEnds()函数(等到动画结束后就会执行cb这个回调函数了)
}
}
}
});
} if (vnode.data.show) {
toggleDisplay && toggleDisplay();
enterHook && enterHook(el, cb);
} if (!expectsCSS && !userWantsControl) {
cb();
}
}

resolveTransition会根据transitioin里的name属性自动拼凑css名,如下:

function resolveTransition (def) {        //第7419行 解析transition
if (!def) {
return
}
/* istanbul ignore else */
if (typeof def === 'object') { //如果def是一个对象
var res = {};
if (def.css !== false) { //如果css不等于false
extend(res, autoCssTransition(def.name || 'v')); //获取class样式
}
extend(res, def);
return res
} else if (typeof def === 'string') {
return autoCssTransition(def)
}
} var autoCssTransition = cached(function (name) {
return {
enterClass: (name + "-enter"),
enterToClass: (name + "-enter-to"),
enterActiveClass: (name + "-enter-active"),
leaveClass: (name + "-leave"),
leaveToClass: (name + "-leave-to"),
leaveActiveClass: (name + "-leave-active")
}
});

例子里执行到这里时返回的如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

回到enter函数,最后会执行whenTransitionEnds函数,如下:

function whenTransitionEnds (       //第7500行 工具函数,当el元素的动画执行完毕后就去执行cb函数
el,
expectedType,
cb
) {
var ref = getTransitionInfo(el, expectedType); //获取动画信息
var type = ref.type; //动画的类型,例如:transition
var timeout = ref.timeout; //动画结束时间
var propCount = ref.propCount; //如果是transition类型的动画,是否有transform动画存在
if (!type) { return cb() }
var event = type === TRANSITION ? transitionEndEvent : animationEndEvent; //如果是transition动画则设置event为transitionend(transition结束事件),否则设置为animationend(animate结束事件)
var ended = 0;
var end = function () {
el.removeEventListener(event, onEnd);
cb();
};
var onEnd = function (e) { //动画结束事件
if (e.target === el) {
if (++ended >= propCount) {
end(); //如果所有的动画都执行结束了,则执行end()函数
}
}
};
setTimeout(function () {
if (ended < propCount) {
end();
}
}, timeout + 1);
el.addEventListener(event, onEnd); //在el节点上绑定event事件,当动画结束后会执行onEnd函数
}

getTransitionInfo用于获取动画的信息,返回一个对象格式,如下:

function getTransitionInfo (el, expectedType) {     //第7533行 获取el元素上上的transition信息
var styles = window.getComputedStyle(el); //获取el元素所有最终使用的CSS属性值
var transitionDelays = styles[transitionProp + 'Delay'].split(', '); //transition的延迟时间 ;比如:["0.5s"]
var transitionDurations = styles[transitionProp + 'Duration'].split(', '); //动画持续时间
var transitionTimeout = getTimeout(transitionDelays, transitionDurations); //获取动画结束的时间
var animationDelays = styles[animationProp + 'Delay'].split(', ');
var animationDurations = styles[animationProp + 'Duration'].split(', ');
var animationTimeout = getTimeout(animationDelays, animationDurations); var type;
var timeout = 0;
var propCount = 0;
/* istanbul ignore if */
if (expectedType === TRANSITION) { //如果expectedType等于TRANSITION(全局变量,等于字符串:'transition')
if (transitionTimeout > 0) {
type = TRANSITION;
timeout = transitionTimeout;
propCount = transitionDurations.length;
}
} else if (expectedType === ANIMATION) { //如果是animation动画
if (animationTimeout > 0) {
type = ANIMATION;
timeout = animationTimeout;
propCount = animationDurations.length;
}
} else {
timeout = Math.max(transitionTimeout, animationTimeout); //获取两个变量的较大值,保存到timeout里
type = timeout > 0
? transitionTimeout > animationTimeout //修正类型
? TRANSITION
: ANIMATION
: null;
propCount = type
? type === TRANSITION //动画的个数 transition可以一次性指定多个动画的,用,分隔
? transitionDurations.length
: animationDurations.length
: 0;
}
var hasTransform =
type === TRANSITION &&
transformRE.test(styles[transitionProp + 'Property']);
return { //最后返回一个动画相关的对象
type: type,
timeout: timeout,
propCount: propCount,
hasTransform: hasTransform
}
}

writer by:大沙漠 QQ:22969969

例子里返回后的对象信息如下:

Vue.js 源码分析(二十八) 高级应用 transition组件 详解

回到whenTransitionEnds函数,等到动画结束时就会执行参数3,也就是enter函数内定义的cb局部函数,该函数最终会移除toClass和activeClass,最后执行afterEnter回掉函数。