🌞

Những ứng dụng tuyệt vời của Renderless component trong Vue

Sửa bài viết này

[TOC]

Để tái sử dụng component trong Vue cũng có lắm ngã dăm ba đường y như React có HOC, render prop, hook. Điểm qua những kỹ thuật/cách làm tương tự trong Vue

Kỹ thuật dùng Slot

Khái niệm Slot trong vue là dạng "đặt gạch" trong component, sau này khi sử dụng ta có thể đưa nội dung khác vào những vị trí đã đặt gạch

Vue không giới hạn số lượng gạch muốn đặt, số lượng tùy thích

<!-- mother.vue -->
Mẹ đặt gạch 2 chỗ header và body cho con nha
<template>
  <div class="card">
    <div class="card-header">
      <slot name="header"></slot>
    </div>
    <div class="card-body">
      <slot name="body"></slot>
    </div>
  </div>
</template>
<!-- con.vue:
Cho con dùng 2 chỗ header và body bằng nội dung mới nhé-->
<mother>
	<template slot="header">
	  <h1>Special Features</h1>
	</template>
	<template slot="body">
		<div>
		    <h5>Fish and Chips</h5>
		    <p>Super delicious tbh.</p>
		</div>
	</template>
</mother>

Đây là những viên gạch có đặt tên <slot name="header"/>

Nếu bạn không khai báo tên cho <slot />, khi đó component ném gạch sẽ được viết (children trong React đấy mà)

<mother>
  Toàn bộ phần này nằm trong slot không đặt tên
</mother>

bạn có thể viết tắt slot="header" thành #header

Ngoài như cầu đặt gạch, bạn sẽ có thêm nhu cầu truyền tải thêm ít dữ liệu qua lại giữa mẹ và con

Khái niệm Slot scope

Đề truyền dữ liệu từ mẹ sang con, chúng ta bind dữ liệu muốn truyền qua slot <slot :ten-bien="gia-tri"/>

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

Component con sẽ nhận dữ liệu thông qua từ khóa slot-scope

<!-- con.vue-->
<mother>
	<template #header slot-scope="{close}">
		<h1>{{ close ? ‘Closed’ : ‘Open’ }}</h1>
	</template>
	<template #body>
		<div slot="body">
		  <h5>Fish and Chips</h5>
		  <p>Super delicious tbh.</p>
		</div>
	</template>
</mother>

Ứng dụng các khái niệm slot, slot-scope vào component modal

Lấy cấu trúc html của bootstrap, chúng ta sẽ cho Modal component có 3 chỗ có thể thay đổi là

  • <slot name="header" />
  • <slot name="body" />
  • <slot name="footer" />
<!-- my-modal.vue -->
<template>
<div class="modal" tabindex="-1" role="dialog">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-header">
        <slot name="header"></slot>        <button type="button" class="close" data-dismiss="modal" aria-label="Close">
          <span aria-hidden="true">×</span>
        </button>
      </div>
      <div class="modal-body">
        <slot name="body"></slot>      </div>
      <div class="modal-footer">
        <slot name="footer"></slot>      </div>
    </div>
  </div>
</div>
</template>

Với 3 cục gạch đã đặt sẵn trong my-modal.vue,

<template>
  <my-modal>
    <!-- kiểu viết tắt của `v-slot` là # -->
    <template #header>
      <h5>Awesome Interruption!</h5>
    </template>
    <template #body>
      <p>Say something....</p>
    </template>
    <template #footer>
      <em>Ahihi</em>
    </template>
  </my-modal>
</template>

Kỹ thuật dùng Component Composition (siêu nhân hợp thể)

Sự kết hợp của nhiều component thành một component mới, dữ dội hơn, như siêu nhân GAO, được gọi là hợp thể component. Từ "khoa học" của nó là Component Composition (trong React cũng có cách làm này)

Lý do phải hợp thể?

<!-- BaseButton.vue -->
<template>
  <button class="nice-button" type="button">
    {{ text }}
  </button>
</template>

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

Nhu cầu thêm mắm, bớt muối cho một món phải nhai đi nhai lại là có. Giả dụ, ta đã có sẵn một component BaseIcon để hiển thị icon, giờ cái Button cùng muốn thêm chút icon cho đời tươi mới, chúng ta xào chung hai món lại để nhai

<template>
  <button class="nice-button" type="button">
    <BaseIcon v-if="leftIcon" :icon="leftIcon"/>
    {{ text }}
    <BaseIcon v-if="rightIcon" :icon="rightIcon"/>
  </button>
</template>

<script>
export default {
  props: ['text', 'leftIcon', 'rightIcon']
}
</script>

Trong đó chúng ta đã thêm hai điều kiện để đặt icon nằm bên trái hay bên phải. Component Button bây giờ cũng được thêm 2 prop rightIcon, leftIcon.

Mọi thứ sẽ rối rắm lên khi có thêm nhu cầu đưa cái spinner vào trong button, khi nào đang loading thì hiện cái spinner này

<template>
  <button class="nice-button" type="button">
    <BaseSpinner v-if="isLoading"/>
    <template v-else>
      <BaseIcon v-if="leftIcon" :icon="leftIcon"/>
      {{ text }}
      <BaseIcon v-if="rightIcon" :icon="rightIcon"/>
    </template>
  </button>
</template>

Chỉ mới thêm chút đường sữa thôi, mà món ăn sắp thành cháo heo thập cẩm khó nuốt.

Với nhiều gia vị được yêu cầu bỏ vào của bọn khách hàng không biết gì về nấu nướng. Món ngon bây giờ thành đặc sản mà đứa nào đó muốn nấu tiếp, sửa đổi do quá mặn, thì cũng bất lực vì không biết đã thêm quá nhiều muối hay nhiều nước mắm.

Một cách nấu khác với slot

Trong cuốn bí kíp 100 cách nấu ngon của Vue.js, nó cho chúng ta cách làm khác đã đề cập ở trên là slot

<template>
  <button class="nice-button" type="button">
    <slot/>
  </button>
</template>

Đây là kiểu món tao đã nấu xong, nếu mày muốn bỏ thêm bất cứ gì đó, dùng món này tao đã nấu xong như một nguyên liệu cho món mới, chứ tao ko sửa lại món của tao.

<BaseButton>
  Submit
  <BaseIcon icon="arrow-right"/>
</BaseButton>

Việc này cũng có ít tranh cãi trong giới đầu bếp, nếu tao phải phục vụ món ăn đó cho một trăm thực khách, tức là tao phải lặp lại việc order 100 gia vị Button về rồi tự nấu thêm 100 lần nữa? vi phạm nguyên tắc nghề nghiệp DRY (DON'T REPEAT YOURSELF).

Đúng là vi phạm nguyên tắc nghề, nhưng nó lại đảm bảo KISS (Keep it simple stupid - ĐƠN GIẢN NHẤT CÓ THỂ). Nói chung các bạn cũng phải thõa hiệp được này mất kia.

<template>
  <BaseButton @click="sendForm">
    <BaseSpinner v-if="isLoading"/>
    <template v-else>
      Submit
      <BaseIcon icon="arrow-right"/>
    </template>
  </BaseButton>
</template>

Kỹ thuật Renderless Component (người vô hình chỉ mang logic)

Nếu bạn đã viết React, nó cũng có khái niệm tương tự chính là render prop

Một component trong Vue có thể không render bất cứ gì cả, nếu chỉ đơn giản là chứa các function, thực hiện logic tính toán.

Nó giống như cái ổ điện, nó chỉ biết làm một chuyện là cấp điện cho chui cắm, còn cái chui đó nối tới bóng đèn, máy tính, tủ lạnh, máy quạt là chuyện của người cắm điện.

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

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

Một component để làm transition như trên không render html, không quan tâm cái nó hiển thị là gì. Nó chỉ bổ sung hiệu ứng fade cho component.

Có thể viết nó ở dạng này, truyền thêm ít dữ liệu cho đứa con

Vue.component('renderless-component-example', {
	render() {
		return this.$scopedSlots.default({
			exampleProp: 'vuilaptrinh.com'
		})
	}
})

Tại sao dùng Renderless component mà không dùng mixin hay directive?

Để tái sử dụng code trong Vue, ngoài renderless component ra còn có thể dùng mixin hoặc custom directive. Cả 3 cách đều có thể dùng thay thế cho nhau được, vấn đề là mức độ tường minh của 3 thằng khác nhau.

Xét theo thứ tự tường mình từ thấp đến cao:

  1. Custom directive
  2. Mixin
  3. renderless component

Mixin thì bị vấn đề, nếu khai báo một số state, hoặc phương thức bên trong mixin, sau đó trộn chung với 1 component, không rõ ràng trực quan như là dùng một renderless component với prop

Ứng dụng renderless component làm ổ cắm mạng cấp dữ liệu internet

Nhu cầu này sẽ rất quen thuộc, tạo một network request lúc component mounted() để lấy dữ liệu.

Chúng ta tạo ra một component chuyên làm nhiệm vụ này

// src/components/DataList.js
import axios from 'axios'

export default {
	props: {
		baseUrl: {
			type: String,
			default: 'https://jsonplaceholder.typicode.com'
		},
		endpoint: {
			type: String,
			required: true
		},
		// dùng để giới hạn kết quả query
		filter: {
			type: Object
		}
	},
	data() {
		return {
			api: axios.create({ baseURL: this.baseUrl }),
			data: null,
			error: null,
			loading: false,
		}
	},
	watch: {
		// load dữ liệu từ endpoint trong lần render đầu tiên
		// và khi giá trị filter thay đổi
		filter: {
			immediate: true,
			handler: 'load'
		}
	},
	methods: {
		// xử lý tất cả type: post, update, delete, get, put bằng một hàm duy nhất
		async query(type, ...params) {
			// tránh việc gọi load liên tục
			if (this.loading) return;

			this.loading = true;

			try	{
				const response = await this.api[type](...params);
				this.data = response.data;
				this.error = null;
				this.$emit('success', response);
			} catch (error) {
				this.data = null;
				this.error = error.response;
				this.$emit('error', error);
			}
			this.loading = false;
		},
		load() {
			return this.query('get', this.endpoint, { params: this.filter })
		}
	},
	render() {
		// đưa toàn bộ dữ liệu và phương thức xuống con thông qua slot scope
		return this.$scopedSlots.default({
			data: this.data,
			error: this.error,
			load: this.load,
			loading: this.loading
		})
	}
}

Renderless component <DataList/> sẽ làm nhiệm vụ fetch dữ liệu từ một API, endpoint được cung cấp qua prop, sau đó nó sẽ quăng kết quả, lỗi, trạng thái hiện tại về cho component con

<data-list endpoint="posts">
	<div slot-scope="{ data: posts, error, loading }">
		<span v-if="loading">Loading...</span>
		<span v-else-if="error">Error while fetching data!</span>
		<ul v-else>
			<li v-for="post in posts" :key="post.id">
				<h3>{{ post.title }}</h3>
				<p>{{ post.body }}</p>
			</li>
		</ul>
	</div>
<data-list>

Thêm phần phân trang, chúng ta dùng giá trị filter

<data-list endpoint="posts" :filter="{ page }">
	<div slot-scope="{ data: posts, error, loading }">
		<span v-if="loading">Loading...</span>
		<span v-else-if="error">Error while fetching data!</span>
		<ul v-else>
			<li v-for="post in posts" :key="post.id">
				<h3>{{ post.title }}</h3>
				<p>{{ post.body }}</p>
			</li>
		</ul>
		<button @click="page = 1">1</button>
    <button @click="page = 2">2</button>
    <button @click="page = 3">3</button>
	</div>
<data-list>

Bởi vì đã setup watch trên giá trị filter, nên khi thay đổi giá trị của page, nó sẽ gọi lại hàm load()

Xong phần Rờ trong CRUD, giờ đến Create-Update-Delete

Chúng ta cần tách phần code ở trên ra thành một mixin để sử dụng vào component detail. Chúng ta sẽ đưa nó vào một mixin là query.js

// src/components/mixins/query.js
import axios from 'axios';

export default {
  props: {
    baseUrl: {
      type: String,
      // The JSONPlaceholder API is a fake API
      // basically a Lorem Ipsum JSON API.
      default: `https://jsonplaceholder.typicode.com`,
    },
    endpoint: {
      type: String,
      required: true,
    },
  },
  data() {
    return {
      // Create a new axios instance.
      // See: https://github.com/axios/axios#creating-an-instance
      api: axios.create({ baseURL: this.baseUrl }),
      data: null,
      error: null,
      loading: false,
    };
  },
  methods: {
    // The `query` method will handle
    // different query types for us.
    async query(type, ...params) {
      // If we're currently loading content
      // we don't submit an additional request.
      if (this.loading) return;

      this.loading = true;
      try {
        const response = await this.api[type](...params);
        this.data = response.data;
        this.error = null;
        this.$emit(`success`, response);
      } catch (error) {
        this.data = null;
        this.error = error.response;
        this.$emit(`error`, error);
      }
      this.loading = false;
    },
  },
};

Với component để thêm-xóa-sửa

// src/components/DataModel.js
import queryMixin from './mixins/query'

export default {
	mixins: [queryMixin],
	props: {
		entity: {
			type: Object,
		},
		id: {
			type: [Number, String]
		}
	},
	data() {
		return {
			data: this.entity || null,
		}
	},
	create() {
		// nếu có id và chưa có dữ liệu, chúng ta gọi fetch dữ liệu
		if (this.id && !this.data) this.find();
	},
	methods: {
		create(data) {
			return this.query('post', this.endpoint, data)
		},
		destroy() {
			return this.query('delete', `${this.endpoint}/${this.id}`)
		},
		find() {
      return this.query('get', `${this.endpoint}/${this.id}`);
    },
    update(data) {
      return this.query('patch', `${this.endpoint}/${this.id}`, data);
    },
	},
	render() {
		return this.$scopedSlots.default({
			create: this.create,
			data: this.data,
			destroy: this.destroy,
			loading: this.loading,
			update: this.update
		})
	}
}

Create

<data-model endpoint="posts">
	<div slot-scope="{ data: post, loading, create }"></div>
	<span v-if="loading">Loading...</span>
  <template v-if="post">
    <h3>{{ post.title }}</h3>
    <p>{{ post.body }}</p>
  </template>
  <form @submit.prevent="create(newPost)">
  	<label>
      Title: <input v-model="newPost.title">
    </label>
    <label>
      Body: <input v-model="newPost.body">
    </label>
    <button :disabled="loading">
      <template v-if="loading">Loading...</template>
      <template v-else>Create</template>
    </button>
  </form>
</data-model>

Với component để Create, chúng ta không truyền dữ liệu và id, vì thế nó không gọi API để lấy dữ liệu, nếu sau khi tạo, chúng ta sẽ nhận giá trị postid.

Cập nhập, giống hệt với tạo ở trên, có điều có thêm id

<data-model endpoint="posts" :id="1">
  <div slot-scope="{ data: post, loading, update }">
    <span v-if="loading">Loading...</span>
    <template v-if="post">
      <h3>{{ post.title }}</h3>
      <p>{{ post.body }}</p>
    </template>

    <form @submit.prevent="update(post);">
      <label>
        Title: <input v-model="post.title">
      </label>
      <label>
        Body: <input v-model="post.body">
      </label>
      <button :disabled="loading">
        <template v-if="loading">Loading...</template>
        <template v-else>Update</template>
      </button>
    </form>
  </div>
</data-model>

Delete

<data-model endpoint="posts" :id="1" @success="deleted = true">
  <div slot-scope="{ delete }">
    <p v-if="deleted">
      The post was successfully deleted.
    </p>
    <button :disabled="loading">
      <template v-if="loading">Loading...</template>
      <template v-else>Delete</template>
    </button>
  </div>
</data-model>

Tài liệu tham khảo

Building Renderless Components to Handle CRUD Operations in Vue.js

Vue.js Pattern for Async Requests: Using Renderless Components

Renderless Components in Vue.js

Composing Components in Vue.js

Using Slots In Vue.js

Creating Reusable Transitions in Vue

Initializing...