前端开发者学堂 - fedev.cn

Vue中的无渲染组件

发布于 大漠

特别声明:此篇文章内容来源于@Adam Wathan的《Renderless Components in Vue.js》一文。

不知道您是否以前有过在第三方组件库中提取过组件的经历,在提取组件的过程中发现需要做一些小的调整,而且也避不开提取整个包。比如像自定义的下拉框,日历或自完匹配等组件,而这些组件可能非常的复杂,需要处理许多意想不到的边界情况。

有很多库在处理这些复杂边界情况做得很好,但是它们通常都有一个破坏性的缺点:很难或不可能定制它们的外观

比如标签输入控件(Tag input控件):

这个组件有一些有趣的特性:

  • 它不会让你重复添加
  • 它不会让你添加空标签
  • 标签之间会自动添加空格
  • 当用户按下Enter键时,会自动添加标签
  • 当用户单击标签中的x时会删除标签

如果你在项目中需要这样的组件,并将其作为包进行打包并卸载,那么这种逻辑肯定会让你节省一些时间和精力。

如果你需它看起来有一点不一样呢?

此组件与前面的组件具有相同的行为,但布局和样式风格有明显的差异:

你可以尝试着通过奇特CSS和组件配置选项的组合来支持这两种布局,但是还有更好的方法。

作用域插槽

在Vue中,slot是一个组件中的占位符元素,由父或者消费者传递的内容来替代。

如果您从示接触过Vue中的slot相关知识,建议您花一点时间阅读前段时间整理的学习笔记《Vue组件内容分发(slot》。

<!-- Card.vue -->
<template>
    <div class="card">
        <div class="card-header">
            <slot name="header"></slot>
        </div>
        <div class="card-body">
            <slot name="body"></slot>
        </div>
    </div>
</template>

<!-- Parent or Consumer -->
<card>
    <h1 slot="header">Special Features</h1>
    <div slot="body">
        <h5>Fish and Chips</h5>
        <p>Super delicious tbh.</p>
    </div>
</card>

<!-- Renders: -->
<div class="card">
    <div class="card-header">
        <h1>Special Features</h1>
    </div>
    <div class="card-body">
        <div>
            <h5>Fish and Chips</h5>
            <p>Super delicious tbh.</p>
        </div>
    </div>
</div>

作用域插槽(slot-scope)和常规的插槽(slot)一样,但有能力将参数从子组件传递到父组件或消费者上

常规的slot就像将HTML传递给组件,作用域插槽类似于传递接受数据并返回HTML的回调。

通过向子组件中的<slot>元素添加一些props,将参数传递给父元素,并且父元素通过将它们从特殊的slot-scope属性解构来访问这些参数。

解构:slot-scope 的值实际上是一个可以出现在函数签名参数位置的合法的 JavaScript 表达式。这意味着在受支持的环境 (单文件组件或现代浏览器) 中,您还可以在表达式中使用 ES2015 解构。

这里有一个LinksList组件的例子,它为每个列表标签设置了一个作用域插槽,将将每个列表标签的数据通过一个:linkprops传递给父元素:

<!-- LinkList.vue -->
<template>
    <!-- ... -->
    <li v-for="link in links">
        <slot name="link" :link="link"></slot>
    </li>
    <!-- ... -->
</template>

<!-- Parent or Consumer -->
<links-list>
    <a slot="link" slot-scope="{ link }" :href="link.href">
        {{ link.title }}
    </a>
</links-list>

通过在LinksList组件中添加:link属性到<slot>元素,父节点(父元素或消费者)现在可以通过slot-scope访问它,并在slot模板中使用它。

Slot属性类型

你可以将任何东西传递给一个插槽,但是我发现将每个Slot属性看作这三种类型之一是非常有用的。

数据(data)

数据属性最简单的类似就是dataStringNumbersBooleanArrayObject等等。

links示例中,link是一个数据属性的示例,它只是一个具有一些属性的对象:

<!-- LinksList.vue -->
<template>
    <!-- ... -->
        <li v-for="link in links">
            <slot name="link" :link="link"></slot>
        </li>
    <!-- ... -->
</template>

<script>
export default {
    data() {
        return {
            links: [
                { 
                    href: 'http://...', 
                    title: 'First Link', 
                    bookmarked: true 
                },
                { 
                    href: 'http://...', 
                    title: 'Second Link', 
                    bookmarked: false 
                },
                // ...
            ]
        }
    }
}
</script>

然后,父元素(或消费者)可以渲染该数据或使用它来决定要渲染的内容:

<!-- Parent or Consumer -->
<links-list>
    <div slot="link" slot-scope="{ link }">
        <star-icon v-show="link.bookmarked"></star-icon>
        <a :href="link.href">
            {{ link.title }}
        </a>
    </div>
</links-list>

动作(Actions)

动作属性是由子组件提供的功能,父组件(或者消费者)可以调用它来调用子组件中的某些行为。比如,我们可以将bookmark操作(Action)传递给链接的父节点:

<!-- LinksList.vue -->
<template>
    <!-- ... -->
    <li v-for="link in links">
        <slot name="link" :link="link" :bookmark="bookmark"></slot>
    </li>
    <!-- ... -->
</template>

<script>
    export default {
        data() {
            // ...
        },
    methods: {
        bookmark(link) {
            link.bookmarked = true
        }
    }
}
</script>

当用户单击未收藏链接旁的button时,父节点可以调用该操作:

<!-- Parent/Consumer -->
<links-list>
    <div slot="link" slot-scope="{ link, bookmark }">
        <star-icon v-show="link.bookmarked"></star-icon>
        <a :href="link.href">{{ link.title }}</a>
        <button v-show="!link.bookmarked" @click="bookmark(link)">Bookmark</button>
    </div>
</links-list>

绑定(Blindings)

绑定是属性或事件处理程序的集合,它们应该使用v-bindv-on绑定到特定的元素上。当你想要封装带有交互的元素时,这些方法非常有用。

例如,我们可以在组件自身中提供bookmarkButtonAttrsbookmarkButtonEvents这些绑定来处理组件的相关细节,而不是让消费者自己通过v-show@click来做相关的处理:

<!-- LinksList.vue -->
<template>
    <!-- ... -->
    <li v-for="link in links">
        <slot name="link"
            :link="link"
            :bookmark="bookmark"
            :bookmarkButtonAttrs="{
                style: [ link.bookmarked ? { display: none } : {} ]
            }"
            :bookmarkButtonEvents="{
                click: () => bookmark(link)
            }"
        ></slot>
    </li>
    <!-- ... -->
</template>

现在,如果消费者喜欢,他们可以将这些绑定应用到书签按钮上,而不压岁要知道他们实际上在做什么:

<!-- Parent/Consumer -->
<links-list>
    <div slot="link" slot-scope="{ link, bookmarkButtonAttrs, bookmarkButtonEvents }">
        <star-icon v-show="link.bookmarked"></star-icon>
        <a :href="link.href">{{ link.title }}</a>
        <button v-bind="bookmarkButtonAttrs" v-on="bookmarkButtonEvents">Bookmark</button>
    </div>
</links-list>

无渲染组件

无渲染组件是一个不需要渲染任何自己的HTML的组件。相反,它只管理状态和行为。它会暴露一个单独的作用域,让父组件或消费者完全控制应该渲染的内容。

无渲染组件可以在没有任何额外的元素情况之下精确的渲染你所传递的内容。

<!-- Parent or Consumer -->
<renderless-component-example>
    <h1 slot-scope="{}">
        Hello world!
    </h1>
</renderless-component-example>

<!-- Renders: -->
<h1>Hello world!</h1>

为什么这个有用?

表现和行为分离

由于无渲染组件只处理状态和行为,所以它们不会对设计或布局强加任何的决策。这意味弟,如果你能够找到一种方法,将所有的行为从UI组件(比如文章开头的标签输入控件)中移出,将将其转换成一个无组件的组件,那么你就可以重用无渲染组件来实现任何布局效果的标签输入控件。

下面有两个标签输入控件,但这次是由一个单一的无渲染组件实现的:

那么这是如何实现的呢?

无渲染组件结构

一个无渲染组件暴露一个单一的作用域插槽,使用者可以提供他们想要渲染的整个模板。

无渲染组件的基本结构如下所示:

Vue.component('renderless-component-example', {
    // Props, data, methods, etc.
    render() {
        return this.$scopedSlots.default({
            exampleProp: 'universe',
        })
    },
})

它没有template,也没有渲染自己的HTML;相反,它使用了一个render()函数,该函数通过任何插槽调用默认的作用域插槽,然后返回结果。

该组件的任何父组件或消费者都可以将exampleProp从插槽作用域中删除,并在其模板中使用它:

<!-- Parent/Consumer -->
<renderless-component-example>
    <h1 slot-scope="{ exampleProp }">
        Hello {{ exampleProp }}!
    </h1>
</renderless-component-example>

<!-- Renders: -->
<h1>Hello universe!</h1>

案例

让我们从头开始构建一个无渲染版本的标签输入控件。我们将从一个没有插槽的空白组件开始:

// Renderless Tags Input Component
Vue.component('renderless-tags-input', {
    render () {
        return this.$scopedSlots.default({})
    }
})

还有一个父组件(将div#app当作父组件),它带有一个静态的非交互式UI,将它传递给子组件的slot

<div id="app">
    <renderless-tags-input>
        <div slot-scope="{}" class="tags-input">
            <span class="tags-input-tag">
                <span>Testing</span>
                <span>Design</span>
                <button type="button" class="tags-input-remove">&times;</button>
            </span>
            <input class="tags-input-text" placeholder="Add tag...">
        </div>
    </renderless-tags-input>
</div>

为了让渲染出来的效果好看一点,添加一点CSS代码,在浏览器中将看到的效果如下:

接下来把状态和行为添加到无渲染组件中,并通过slot-scope将其暴露在我们的布局中,从而使这个组件能正常运行。

标签列表

首先,用动态列表替换静态列表。标签输入组件是一个自定义表单控件,就像在原始示例中,标签应该在父节点中,并使用v-model将其绑定到组件上。

我们首先给组件的props添加一个value值,并将其作为一个tags的插槽属性:

// Renderless Tags Input Component
Vue.component('renderless-tags-input', {
    props: ['value'],
    render () {
        return this.$scopedSlots.default({
            tags: this.value
        })
    }
})

接下来,将在父节点中添加v-model绑定到父组件中,将tagsslot-scope提取出来,并使用v-for对其进行迭代:

<div id="app">
    <renderless-tags-input v-model="tags">
        <div slot-scope="{ tags }" class="tags-input">
            <span class="tags-input-tag" v-for="tag in tags">
                <span>{{ tag }}</span>
                <button type="button" class="tags-input-remove">&times;</button>
            </span>
            <input class="tags-input-text" placeholder="Add tag...">
        </div>
    </renderless-tags-input>
</div>

let app = new Vue({
    el: '#app',
    data () {
        return {
            tags: ['Tesing', 'Design']
        }
    }
})

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

现在还没办法在输入框中输入标签之后按回键就能添加标签,但我们可以在浏览器的控制台中修改app.tags的值,来模拟。比如能过.push()tags数组添加几个值,看到的效果:

这个插槽是一个简单数据属性(Data Prop)的好例子。

删除标签

接下来做,点击x按钮删除对应的标签。给组件添加一个新的removeTag方法,并将该方法的引用传递给父组件,当作slot属性。

// Renderless Tags Input Component
Vue.component('renderless-tags-input', {
    props: ['value'],
    methods: {
        removeTag: function (tag) {
            this.$emit('input', this.value.filter(t => t !== tag))
        }
    },
    render () {
        return this.$scopedSlots.default({
            tags: this.value,
            removeTag: this.removeTag
        })
    }
})

然后,将在父组件的按钮上添加@click处理程序,当用户点击该按钮时,对应的标签将会调用removeTag

<div id="app">
    <renderless-tags-input v-model="tags">
        <div slot-scope="{ tags, removeTag }" class="tags-input">
            <span class="tags-input-tag" v-for="tag in tags">
                <span>{{ tag }}</span>
                <button type="button" class="tags-input-remove" @click="removeTag(tag)">&times;</button>
            </span>
            <input class="tags-input-text" placeholder="Add tag...">
        </div>
    </renderless-tags-input>
</div>

你点击每个标签上的x按钮,就可以删除对应的标签了,效果如下:

这个插槽是一个典型的动作属性(Action Prop)的例子。

添加新标签

相对而言,添加新标签要比前面两个更复杂一些。为了能更好的理解其中的原委,先看看在传统的组件中是如何实现它:

// Renderless Tags Input Component
Vue.component('renderless-tags-input', {
    props: ['value'],
    data () {
        return {
            newTag: ''
        }
    },
    methods: {
        removeTag: function (tag) {
            this.$emit('input', this.value.filter(t => t !== tag))
        },
        addTag: function () {
            if (this.newTag.trim().length === 0 || this.value.includes(this.newTag.trim())) {
                return
            }
            this.$emit('input', [...this.value, this.newTag.trim()])
            this.newTag = ''
        }
    },
    render () {
        return this.$scopedSlots.default({
            tags: this.value,
            removeTag: this.removeTag
        })
    }
})

<div id="app">
    <renderless-tags-input v-model="tags">
        <div slot-scope="{ tags, removeTag }" class="tags-input">
            <span class="tags-input-tag" v-for="tag in tags">
                <span>{{ tag }}</span>
                <button type="button" class="tags-input-remove" @click="removeTag(tag)">&times;</button>
            </span>
            <input @keydown.enter.prevent="addTag" v-model="newTag" class="tags-input-text" placeholder="Add tag...">
        </div>
    </renderless-tags-input>
</div>    

我们在newTag属性中跟踪新标记(在添加之前),并使用v-model将该属性绑定到input上。一旦用户按下Enter键,确保标签是有效的,接着将其添加到列表中,然后再清除input里的内容。

这里的问题是,我们如何通过作用插槽来传递v-model的绑定?

如果你深入了解了Vue,你可能知道v-model实际上只是一个:value属性绑定和@input事件绑定的语法糖:

<div id="app">
    <renderless-tags-input v-model="tags">
        <div slot-scope="{ tags, removeTag }" class="tags-input">
            <span class="tags-input-tag" v-for="tag in tags">
                <span>{{ tag }}</span>
                <button type="button" class="tags-input-remove" @click="removeTag(tag)">&times;</button>
            </span>
            <input @keydown.enter.prevent="addTag" :value="newTag" @input="(e) => newTag = e.target.value" class="tags-input-text" placeholder="Add tag...">
        </div>
    </renderless-tags-input>
</div>   

注意:这个时候在你的浏览器中运行上面的代码之后,浏览器将会抛出报错信息:“ReferenceError: newTag is not defined”。如果你和我一样,没整清楚为什么抛错的话,不用纠结,请继续往下阅读。

这意味着我们可以通过一些更改来处理我们的无渲染组件中的这种行为:

  • 向组件添加本地的newTag数据属性
  • 使用:value绑定newTag,传递绑定的属性
  • 使用@keydown.enter绑定addTag@input绑定newTag,传递绑定的事件

继续看代码:

// Renderless Tags Input Component
Vue.component('renderless-tags-input', {
    props: ['value'],
    data () {
        return {
            newTag: ''
        }
    },
    methods: {
        removeTag: function (tag) {
            this.$emit('input', this.value.filter(t => t !== tag))
        },
        addTag: function () {
            if (this.newTag.trim().length === 0 || this.value.includes(this.newTag.trim())) {
                return
            }
            this.$emit('input', [...this.value, this.newTag.trim()])
            this.newTag = ''
        }
    },
    render () {
        return this.$scopedSlots.default({
            tags: this.value,
            removeTag: this.removeTag,
            inputAttrs: {
                value: this.newTag
            },
            inputEvents: {
                input: (e) => { this.newTag = e.target.value },
                keydown: (e) => {
                    if (e.keyCode === 13) {
                        e.preventDefault()
                        this.addTag()
                    }
                }
            }
        })
    }
})

现在我们只需要将这些属性绑定到父元素中的input元素上:

<div id="app">
    <renderless-tags-input v-model="tags">
        <div slot-scope="{ tags, removeTag, inputAttrs, inputEvents }" class="tags-input">
            <span class="tags-input-tag" v-for="tag in tags">
                <span>{{ tag }}</span>
                <button type="button" class="tags-input-remove" @click="removeTag(tag)">&times;</button>
            </span>
            <input v-bind="inputAttrs" v-on="inputEvents" class="tags-input-text" placeholder="Add tag...">
        </div>
    </renderless-tags-input>
</div>   

这个时候你在input中输入值,并按下Enter时可以正常的添加标签了:

显式地添加新标签

在当前的布局中,用户在input元素中输入新标签并按回车键就可以添加新标签。但是很容易想象一个场景,有人可能想要提供一个按钮,用户可以点击按钮添加新的标签。

要实现这样的功能其实很容易,要做的就是将addTag方法的引用传递给slot作用域中:

// Renderless Tags Input Component
Vue.component('renderless-tags-input', {
    props: ['value'],
    data () {
        return {
            newTag: ''
        }
    },
    methods: {
        removeTag: function (tag) {
            this.$emit('input', this.value.filter(t => t !== tag))
        },
        addTag: function () {
            if (this.newTag.trim().length === 0 || this.value.includes(this.newTag.trim())) {
                return
            }
            this.$emit('input', [...this.value, this.newTag.trim()])
            this.newTag = ''
        }
    },
    render () {
        return this.$scopedSlots.default({
            tags: this.value,
            removeTag: this.removeTag,
            inputAttrs: {
                value: this.newTag
            },
            inputEvents: {
                input: (e) => { this.newTag = e.target.value },
                keydown: (e) => {
                    if (e.keyCode === 13) {
                        e.preventDefault()
                        this.addTag()
                    }
                }
            },
            addTag: this.addTag
        })
    }
})

在设计像这样的无渲染的组件时,宁可在插槽属性方面犯错,也不要在其他的地方犯错。当消费者使用的时候,只需要使用真正需要的属性,就算给他一个没用的属性,也不会有什么开销。

以下就是我们所构建的无渲染标签输入组件的示例效果:

实际的组件不包含HTML,而我们定义模板的父组件不包含任何行为。是不是整洁漂亮,对吧。

另一个布局

现在,我们已经有了一个无渲染版本的标签输入组件。我们可以通过编写任何我们想要的HTML,并将提供slot相关的属性,将其运用到正确的位置,从而可以轻松的实现可选的布局。

比如下面的示例,就是使用新的无渲染组件实现的另一种布局效果的标签输入控件:

创建自己固定的容器组件

你可能会看到一些这样的例子,并会想:“哇,每次我需要添加这标签组件的另一个实例时,都需要编写大量的HTML!”。当你需要一个标签输入时,写这个肯定会有更多的工作:

<renderless-tags-input v-model="tags">
    <div class="tags-input" slot-scope="{ tags, removeTag, inputAttrs, inputEvents }">
        <span class="tags-input-tag" v-for="tag in tags">
            <span>{{ tag }}</span>
            <button type="button" class="tags-input-remove" @click="removeTag(tag)">&times;</button>
        </span>

        <input class="tags-input-text" placeholder="Add tag..." v-on="inputEvents" v-bind="inputAttrs">
    </div>
</renderless-tags-input>

这是我们开始示例中的做法。不过有一个更为简单的解决方法:创建一个有容器组件!

<tags-input v-model="tags"></tags-input>

<tags-input>组件看起来像下面这样:

<!-- InlineTagsInput.vue -->
<template>
    <renderless-tags-input :value="value" @input="(tags) => { $emit('input', tags) }">
        <div class="tags-input" slot-scope="{ tag, removeTag, inputAttrs, inputEvents }">
            <span class="tags-input-tag" v-for="tag in tags">
                <span>{{ tag }}</span>
                <button type="button" class="tags-input-remove" @click="removeTag(tag)">&times;</button>
            </span>

            <input class="tags-input-text" placeholder="Add tag..."
                v-bind="inputAttrs"
                v-on="inputEvents"
            >
        </div>
    </renderless-tags-input>
</template>

<script>
    export default {
        props: ['value'],
    }
</script>

现在你可以在任何你需要特定布局的代码中使用该组件:

<tags-input v-model="tags"></tags-input>

比如文章开头演示的两个示例。

更牛逼的组件

一旦你有了无渲染组件这样的意识,那么你可用这样的方式来做更疯狂的事情。例如,这里有一个fetch-data组件,它以一个url作为props的属性值,从该URL中获取JSON数据,并传给父组件。比如下面这样的一个示例:

总结

将一个组件分解成一个表示组件(Presentational Component)和一个无渲染组件(Renderless Component)是一种非常有用的模式,可以使组件重用变得更加容易。虽然如此,但也并不代表总是值得的。

如果你满足下面这些条件,建议你使用无渲染组件这种方式:

  • 你正在构建一个库,并且希望使用者能够更轻易地自定义组件的外观(样式效果)
  • 你项目中有多个组件,其行为或功能非常相似,但布局不同

如果一个组件看起来和它使用的地方是一样的或者只需要一个组件,那么就没有必要这样做,因为将所有东西都保存在一个组件中,会让你的事情变得更简单得多。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/vue/renderless-components-in-vuejs.htmlnike free run 5.0 Classic