如何在Vue中构建可重用的Transition

发布于 大漠

在Vue中的transitionanimation都是一些很棒的东东。它们可以让我们的组件带有一定的动效效果。在《Vue的transition》和《Vue的animation》中分别学习了transitionanimation在Vue组件中的运用。这两个特性可以让Web元元素可以像animation.css库中提供的效果一样,实现一些过渡甚至是简单的动画效果。让整个效果看起来很好。

如果我们可以将它们封装到组件中,并且在多个项目中重用,是不是会给我们带来更多的益处呢?在前面两篇文章中,我们了解了在Vue中怎么使用transitionanimation,今天我们来学习几种定义transtion的方法,并且深入研究它们可以真正重用的方法。

创建Vue项目

为了节省大家更多的时间,在这篇文章中咱们直接使用Vue Cli的来构建演示项目:

vue create vue-transitions

因为我们将会涉及多个使用transition方法,在这里通过相关的分支来区分。

git checkout -b demo1

接下来进入实际的案例中,以来阐述Vue中如何构建可重用的transition

示例项目Github对应的地址在这里

原始的transition组件和CSS

在《Vue的transition》一文中,我们了解到,在Vue中定义transition最简单的方法是使用Vue中的<transition><transition-group>。这需要为它们定义一个name和一些CSS。比如:

<!-- transitionDemo.vue -->
<template>
    <div class="demo">
        <div class="toggle toggle--text">
            <input type="checkbox" id="toggle--text" class="toggle--checkbox">
            <label class="toggle--btn" for="toggle--text" data-label-on="show"  data-label-off="hidden" @click="toggle"></label>
        </div>

        <transition name="fade">
            <p v-if="show">Hello W3cplus (^_^) !!!</p>
        </transition>
    </div>
</template>

<script>
    export default {
        name: 'transitionDemo',
        data () {
            return {
                show: true
            }
        },
        methods: {
            toggle() {
                this.show = !this.show
            }
        }
    }
</script>

<style scoped>
.fade-enter-active,
.fade-leave-active {
    transition: opacity .3s;
}

.fade-enter,
.fade-leave-to {
    opacity: 0;
}

/* === 默认样式省略 == */
</style>

点击按钮,你将看到的效果如下:

看起来很容易,对吧?然而,这种方法有一个问题。这个过渡效果不能在另一个项目中重用

封装一个transition组件

如果我们将前面逻辑封装成一个Vue组件,将会发生什么情况呢?同样的,把Git分支切换到demo2中。接着我们创建一个FadeTransition.vue组件。

<!-- FadeTransition.vue -->
<template>
    <transition name="fade">
        <slot />
    </transition>
</template>

<script>
    export default {
        name: 'FadeTransition'
    }
</script>

<style scoped>
    .fade-enter-active,
    .fade-leave-active {
        transition: opacity .3s;
    }

    .fade-enter,
    .fade-leave-to {
        opacity: 0;
    }
</style>

该组件很简单,和Vue基本的<transition>几乎类似,不同之处是该组件使用了slot。在使用FadeTransition组件时,可以通过slot传递你想要的内容。比如:

<!-- App.vue -->
<template>
    <div id="app">
        <div class="toggle toggle--text">
                <input type="checkbox" id="toggle--text" class="toggle--checkbox">
                <label class="toggle--btn" for="toggle--text" data-label-on="show"  data-label-off="hidden" @click="toggle"></label>
            </div>

        <FadeTransition>
            <div class="box" v-if="show"></div>
        </FadeTransition>
    </div>
</template>

<script>
    import FadeTransition from './components/FadeTransition'

    export default {
        name: 'app',
        components: {
            FadeTransition
        },
        data () {
            return {
                show: true
            }
        },
        methods: {
            toggle () {
                this.show = !this.show
            }
        }
    }
</script>

这个时候看到的效果如下:

这个示例比前面的示例稍微好一点,如果想传递其他特定的值(props)给transition,比如mode或者一些钩子函数,这就有点难搞了。

幸运的是,Vue中有一个特性,允许我们将用户指定的任何额外的props和侦听器传递给组件。如果你还不知道,可以通过$attrs来访问额外的props,并且结合v-bind一起使用,将它们绑定为props

$attrs:包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (classstyle 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (classstyle 除外),并且可以通过 v-bind="$attrs" 传入内部组件 —— 在创建高级别的组件时非常有用

这种方式同样适用于$listener,通常和v-on绑定使用传入内部组件。

demo2的分支上,切到demo3分支,在FadeTransition组件上新增v-bind="$attrs"v-on="$listeners"

<!-- FadeTransition.vue -->
<template>
    <transition name="fade" v-bind="$attrs" v-on="$listeners">
        <slot />
    </transition>
</template>

<!-- App.vue -->
<template>
    <div id="app">
        <div class="toggle toggle--text">
            <input type="checkbox" id="toggle--text" class="toggle--checkbox">
            <label class="toggle--btn" for="toggle--text" data-label-on="Box"  data-label-off="Circle" @click="toggle"></label>
        </div>

        <FadeTransition mode="out-in">
            <div key="box" v-if="show" class="box"></div>
            <div key="circle" v-else class="circle"></div>
        </FadeTransition>
    </div>
</template>

<script>
    import FadeTransition from './components/FadeTransition'

    export default {
        name: 'app',
        components: {
            FadeTransition
        },
        data () {
            return {
                show: true
            }
        },
        methods: {
            toggle () {
                this.show = !this.show
            }
        }
    }
</script>

这个示例实现了一个盒子过渡成一个圆的效果:

现在FadeTransition组件能像常规的<transition>一样接受事件监听和props,这样也让该组件更加可重用。既然如此,我们给组件添加一个durationprops

Vue的<transition>组件提供了一个duration属性,可以用来定制一个显性的过渡持续时间。在很多情况下,Vue可以自动得出过渡效果的完成时机。默认情况下,Vue会等待其在过渡效果的 根元素的第一个transitionendanimationend事件。然而也可以不这样设定,比如,我们可以拥有一个精心设计过的过渡效果,其中一些嵌套的内部元素相比于过渡效果的根元素有延迟或更长的过渡效果。在这个时候,就可以用上<transition>组件的duration属性

在我们的示例中,我们需要通过组件的props来控制CSS的animationtransition。实现起来并不太复杂,我们不在CSS中显式设置animation-durationtransition-duration样式,而是在Vue组件中,通过组件生命周期的钩子函数来实现。比如:

<!-- FadeTransition.vue -->
<template>
    <transition 
        name="fade" 
        v-bind="$attrs" 
        v-on="hooks"
        enter-active-class="fadeIn"
        leave-active-class="fadeOut">
        <slot />
    </transition>
</template>

<script>
    export default {
        name: 'FadeTransition',
        props: {
            duration: {
                type: Number,
                default: 300
            }
        },
        computed: {
            hooks() {
                return {
                    beforeEnter:this.setDuration,
                    afterEnter: this.cleanUpDuration,
                    beforeLeave: this.setDuration,
                    afterLeave: this.cleanUpDuration,
                    ...this.$listeners
                }
            }
        },
        methods: {
            setDuration(el) {
                el.style.animationDuration = `${this.duration}ms`
            },
            cleanUpDuration(el){
                el.style.animationDuration= ''
            }
        }
    }
</script>

<style scoped>
    @keyframes fadeIn {
        from {
            opacity: 0;
        }
        to {
            opacity: 1;
        }
    }

    .fadeIn {
        animation-name: fadeIn;
    }

    @keyframes fadeOut {
        from {
            opacity: 1;
        }
        to {
            opacity: 0;
        }
    }
    .fadeOut {
        animation-name: fadeOut;
    }
</style>

<!-- App.vue -->

<template>
    <div id="app">
        <div class="toggle toggle--text">
            <input type="checkbox" id="toggle--text" class="toggle--checkbox">
            <label class="toggle--btn" for="toggle--text" data-label-on="Box"  data-label-off="Circle" @click="toggle"></label>
        </div>
        <div class="duration">
            <label for="duration">持续时间(Duration):</label>
            <input type="range" min="100" max="3000" v-model="duration" id="duration" />
            <span>{{duration}}ms</span>
        </div>
        <FadeTransition mode="out-in" :duration="durationNumber">
            <div key="box" v-if="show" class="box"></div>
            <div key="circle" v-else class="circle"></div>
        </FadeTransition>
    </div>
</template>

<script>
import FadeTransition from './components/FadeTransition'

export default {
    name: 'app',
    components: {
        FadeTransition
    },
    data () {
        return {
            show: true,
            duration: 300
        }
    },
    methods: {
        toggle () {
            this.show = !this.show
        }
    },
    computed: {
        durationNumber() {
            return parseInt(this.duration);
        }
    }
}
</script>

更详细的代码,可以把分支切换到demo4下。

列表过渡

Vue除了提供了<transition>组件之外还另外提供了一个<transition-group>组件。而这个组件也常常被称为列表过渡。该组件有几个特点:

  • 不同于<transition>,它会以一个真实元素呈现:默认是一个<span>,咱们可以通过tag特性更换为其他元素
  • 过渡模式不可用,因为我们不一在相互切换特有的元素
  • 内部元素总是需要提供唯五的key属性值

回到我们的话题中来,如果封装的FadeTransition组件面对列表这样的过渡效果呢?又应该如何呢?

可以大家会认为最简单的方式,就是重新构建一个Vue组件,比如FadeTransitionGroup,并将<transition>换成<transition-group>即可。这样做事实上并不会有问题,如果我们能做得更好,是不是应该选择更好的方式。比如说,同样维护前面创建的FadeTransition组件,而且该组件能让我们在<transition><transition-group>之间进行切换。

幸运的是,在Vue中,我们可以通过渲染(render)函数或借助componentis属性(动态组件)来实现这一点。接下来把分支切换到demo5

<!-- FadeTransition.vue -->
<template>
    <component 
        :is="type"
        :tag="tag"
        v-bind="$attrs" 
        v-on="hooks"
        enter-active-class="fadeIn"
        leave-active-class="fadeOut"
        move-class="fade-move">
        <slot />
    </component>
</template>

<script>
    export default {
        name: 'FadeTransition',
        props: {
            duration: {
                type: Number,
                default: 300
            },
            group : {
                type: Boolean,
                default: false
            },
            tag: {
                type: String,
                default: 'div'
            }
        },
        computed: {
            type() {
                return this.group ? 'transition-group' : 'transition'
            },
            hooks() {
                return {
                    beforeEnter:this.setDuration,
                    afterEnter: this.cleanUpDuration,
                    beforeLeave: this.setDuration,
                    afterLeave: this.cleanUpDuration,
                    leave: this.setAbsolutePosition,
                    ...this.$listeners
                }
            }
        },
        methods: {
            setDuration(el) {
                el.style.animationDuration = `${this.duration}ms`
            },
            cleanUpDuration(el){
                el.style.animationDuration= ''
            },
            setAbsolutePosition(el) {
                if (this.group) {
                    el.style.position = 'absolute'
                }
            }
        }
    }
</script>

<style scoped>
    @keyframes fadeIn {
        from {
            opacity: 0;
        }
        to {
            opacity: 1;
        }
    }

    .fadeIn {
        animation-name: fadeIn;
    }

    @keyframes fadeOut {
        from {
            opacity: 1;
        }
        to {
            opacity: 0;
        }
    }
    .fadeOut {
        animation-name: fadeOut;
    }
    .fade-move {
        transition: transform .3s ease-out;
    }
</style>

<!-- App.vue -->
<template>
    <div id="app">
        <div class="toggle toggle--btn" @click="addItem">添加</div>
        <div class="duration">
            <label for="duration">持续时间(Duration):</label>
            <input type="range" min="100" max="3000" v-model="duration" id="duration" />
            <span>{{duration}}ms</span>
        </div>
        <div class="tips">提示:点击下面的盒子,对应盒子会立即删除</div>
        <div class="wrapper">
            <FadeTransition group :duration="durationNumber">
                <div class="box" v-for="(item, index) in list" @click="remove(index)" :key="item"></div>
            </FadeTransition>
        </div>
    </div>
</template>

<script>
    import FadeTransition from './components/FadeTransition'

    export default {
        name: 'app',
        components: {
            FadeTransition
        },
        data () {
            return {
                show: true,
                duration: 300,
                list: [1,2,3,4,5]
            }
        },
        methods: {
            toggle () {
                this.show = !this.show
            },
            remove(index) {
                this.list.splice(index, 1)
            },
            addItem() {
                let randomIndex = Math.floor(Math.random() * this.list.length)
                this.list.splice(randomIndex, 0, Math.random())
            }
        },
        computed: {
            durationNumber() {
                return parseInt(this.duration);
            }
        }
    }
</script>

效果如下:

这里有一个小细节需要注意,当元素删除时,我们必须为每个元素的position设置为absolute,以实现其他元素的平滑移动。另外手动添加了一个move类,指定transform持续的时间。

再做一些调整,并通过mixin中提取JavaScript逻辑,这样就可以将其应用于新的过渡组件。使用的时候,只需要将其放入到下一个项目中即可。

<!-- src/mixins/baseTransition.js -->
export default {
    inheritAttrs: false,
    props: {
        duration: {
            type: [Number, Object],
            default: 300
        },
        group: {
            type: Boolean,
            default: false
        },
        tag: {
            type: String,
            default: 'div'
        },
        origin: {
            type: String,
            default: ''
        },
        styles: {
            type: Object,
            default: () => {
                return {
                    animationFillMode: 'both',
                    animationTimingFunction: 'ease-out'
                }
            }
        }
    },
    computed: {
        componentType() {
            return this.group ? 'transition-group' : 'transition'
        },
        hooks() {
            return {
                beforeEnter: this.beforeEnter,
                afterEnter: this.cleanUpStyles,
                beforeLeave: this.beforeLeave,
                leave: this.leave,
                afterLeave: this.cleanUpStyles,
                ...this.$listeners
            }
        }
    },
    methods: {
        beforeEnter(el) {
            let enterDuration = this.duration.enter ?  this.duration.enter : this.duration
            el.style.animationDuration = `${enterDuration / 1000}s`
            this.setStyles(el)
        },
        cleanUpStyles(el) {
            Object.keys(this.styles).forEach(key => {
                const styleValue = this.styles[key]
                if (styleValue) {
                    el.style[key] = ''
                }
            })
            el.style.animationDuration = ''
        },
        beforeLeave(el) {
            let leaveDuration = this.duration.leave ? this.duration.leave : this.duration
            el.style.animationDuration = `${leaveDuration / 1000}s`
            this.setStyles(el)
        },
        leave(el) {
            this.setAbsolutePosition(el)
        },
        setStyles(el) {
            this.setTransformOrigin(el)
            Object.keys(this.styles).forEach(key => {
                const styleValue = this.styles[key]
                if (styleValue) {
                    el.style[key] = styleValue
                }
            })
        },
        setAbsolutePosition(el) {
            if (this.group) {
                el.style.position = 'absolute'
            }
            return this
        },
        setTransformOrigin(el) {
            if (this.origin) {
                el.style.transformOrigin = this.origin
            }
            return this
        }
    }
}

<!-- src/mixins/index.js -->
import baseTransition from './baseTransition'

export {
    baseTransition
}

<!-- FadeTransition.vue -->
<template>
    <component :is="componentType"
                :tag="tag"
                v-bind="$attrs"
                v-on="hooks"
                enter-active-class="fadeIn"
                move-class="fade-move"
                leave-active-class="fadeOut">
        <slot></slot>
    </component>
</template>

<script>
    import {baseTransition} from '../mixins/index.js'
    export default {
        name: 'FadeTransition',
        mixins: [baseTransition]
    }
</script>

<style>
    @keyframes fadeIn {
        from {
            opacity: 0;
        }
        to {
            opacity: 1;
        }
    }
    .fadeIn {
        animation-name: fadeIn;
    }
    @keyframes fadeOut {
        from {
            opacity: 1;
        }
        to {
            opacity: 0;
        }
    }
    .fadeOut {
        animation-name: fadeOut;
    }
    .fade-move {
        transition: transform .3s ease-out;
    }
</style>

更详细的您可以fork一份@BinarCodevue2-transitions

另外按照这样的方式,可以把Animate.css库中动画效果都封装成对应的Vue组件。感兴趣的同学,不仿一试。

小结

文章中从一个基本的<transition>示例开始,最后通过可调用duration<transition-group>来创建可重用的<transition>组件。文章中的一些技巧只是一些最基本的技巧,你也可以根据自己的需要封装属于自己的过渡组件,如比@BinarCodevue2-transitions 一样,甚至你还可以将Animate.css库按照@BinarCode的方式将所有动效都封装成独立的Vue组件,从而实现可以在多处调用的目标。如果你有更好的方式,欢迎在下面的评论中与我们一起分享。

参考阅读

  • Vue的transition
  • Vue的animation
  • 进入/离开 & 列表过渡
  • Creating Reusable Transitions in VueAir Jordan Spizike 3.5 Shoes