Cómo crear componentes de Vue en un tema de WordPress
¿Estás intrigado por el título y solo quieres ver algo de código? Vaya directamente.
Este tutorial fue escrito para Vue 2 y utiliza “plantillas en línea”. Vue 3 ha dejado de usar esta característica, pero existen alternativas (como poner sus plantillas en etiquetas de script) a las que podría traducir la idea.
Hace unos meses, estaba creando un sitio web de WordPress que requeriría un formulario con un montón de campos condicionales favorables. Se requerían diferentes opciones e información para las distintas elecciones que podíamos realizar en el formulario, y nuestro cliente necesitaba control total sobre todos los campos 1 . Además, el formulario debería aparecer en varios lugares de cada página, con configuraciones ligeramente diferentes.
Y la instancia del encabezado del formulario debía ser mutuamente excluyente con el menú de hamburguesas, de modo que al abrir uno se cerrara el otro.
Y el formulario tenía contenido de texto relevante para SEO.
Y queríamos que la respuesta del servidor presentara algunos lindos comentarios animados.
(Uf.)
Todo parecía lo suficientemente complejo como para no querer manejar todo ese estado manualmente. Recordé haber leído el artículo de Sarah Drasner “Reemplazar jQuery con Vue.js: no es necesario ningún paso de compilación”, que muestra cómo reemplazar los patrones clásicos de jQuery con microaplicaciones simples de Vue. Parecía un buen punto de partida, pero rápidamente me di cuenta de que las cosas se complicarían en el lado PHP de WordPress.
Lo que realmente necesitaban eran componentes reutilizables .
PHP → JavaScript
Me encanta el enfoque estático de las herramientas Jamstack, como Nuxt , y estaba buscando hacer algo similar aquí: enviar el contenido completo desde el servidor y mejorar progresivamente en el lado del cliente.
Pero PHP no tiene una forma integrada de trabajar con componentes. Sin embargo, admite require
archivos -ing dentro de otros archivos 2 . WordPress tiene una require
llamada de abstracción get_template_part
, que se ejecuta en relación con la carpeta del tema y es más fácil trabajar con ella. Dividir el código en partes de la plantilla es lo más parecido a los componentes que proporciona WordPress 3 .
Vue, por otro lado, se trata de componentes, pero solo puede hacer su trabajo después de que la página se haya cargado y esté ejecutando JavaScript .
El secreto de esta unión de paradigmas resulta ser la directiva Vue, menos conocida inline-template
. Sus grandes y maravillosos poderes nos permiten definir un componente Vue usando el marcado que ya tenemos. Es el punto medio perfecto entre obtener HTML estático del servidor y montar elementos DOM dinámicos en el cliente.
Primero, el navegador obtiene el HTML y luego Vue lo obliga a hacer cosas. Dado que el marcado lo crea WordPress, en lugar de Vue en el navegador, los componentes pueden usar fácilmente cualquier información que los administradores del sitio puedan editar. Y, a diferencia de los archivos .vue (que son excelentes para crear más aplicaciones), podemos mantener la misma separación de preocupaciones que usamos para todo el sitio: estructura y contenido en PHP, estilo en CSS y funcionalidad en JavaScript. .
Para mostrar cómo encaja todo esto, crearemos algunas funciones para un blog de recetas. Primero, agregaremos una forma para que los usuarios califiquen recetas. Luego crearemos un formulario de comentarios basado en esa calificación. Finalmente, permitiremos a los usuarios filtrar recetas según etiquetas y calificación.
Construiremos algunos componentes que comparten estado y viven en la misma página. Para que funcionen bien juntos y para que sea más fácil agregar componentes adicionales en el futuro, convertiremos toda la página en nuestra aplicación Vue y registraremos los componentes dentro de ella.
Cada componente vivirá en su propio archivo PHP y se incluirá en el tema usando get_template_part
.
Sentando las bases
Hay algunas consideraciones especiales a tener en cuenta al aplicar Vue a páginas existentes. La primera es que Vue no quiere que cargues scripts dentro de él; si lo haces, envía errores siniestros a la consola. La forma más sencilla de evitar esto es agregar un elemento contenedor alrededor del contenido de cada página y luego cargar scripts fuera de ella (lo cual ya es un patrón común por todo tipo de razones). Algo como esto:
?php /* header.php */ ?body ?php body_class(); ?div
?php /* footer.php */ ? /div !-- #site-wrapper --?php wp_footer(); ?
La segunda consideración es que se debe llamar a Vue al final del elemento del cuerpo para que se cargue después de que el resto del DOM esté disponible para analizar. Pasaremos verdadero como quinto argumento ( in_footer
) de la wp_enqueue_script
función. Además, para asegurarnos de que Vue se cargue primero, lo registraremos como una dependencia del script principal.
?php // functions.phpadd_action( 'wp_enqueue_scripts', function() { wp_enqueue_script('vue', get_template_directory_uri() . '/assets/js/lib/vue.js', null, null, true); // change to vue.min.js for production wp_enqueue_script('main', get_template_directory_uri() . '/assets/js/main.js', 'vue', null, true);
Finalmente, en el guión principal, inicializaremos Vue en el site-wrapper
elemento.
// main.jsnew Vue({ el: document.getElementById('site-wrapper')})
El componente de calificación de estrellas
Nuestra plantilla de publicación única actualmente se ve así:
?php /* single-post.php */ ?article ?php /* ... post content */ ? !-- star rating component goes here --/article
Registraremos el componente de calificación de estrellas y agregaremos algo de lógica para administrarlo:
// main.jsVue.component('star-rating', { data () { return { rating: 0 } }, methods: { rate (i) { this.rating = i } }, watch: { rating (val) { // prevent rating from going out of bounds by checking it to on every change if (val 0) this.rating = 0 else if (val 5) this.rating = 5 // ... some logic to save to localStorage or somewhere else } }})// make sure to initialize Vue after registering all componentsnew Vue({ el: document.getElementById('site-wrapper')})
Escribiremos la plantilla del componente en un archivo PHP separado. El componente constará de seis botones (uno para no clasificados y cinco con estrellas). Cada botón contendrá un SVG con un relleno negro o transparente.
?php /* components/star-rating.php */ ?star-rating inline-template div pRate recipe:/p button @click="rate(0)" svgpath d="..." :fill="rating === 0 ? 'black' : 'transparent'"/svg /button button v-for="(i in 5)" @click="rate(i)" svgpath d="..." :fill="rating = i ? 'black' : 'transparent'"/svg /button /div/star-rating
Como regla general, me gusta darle al elemento superior de un componente un nombre de clase que sea idéntico al del mismo componente. Esto facilita el razonamiento entre marcado y CSS (por ejemplo, star-rating
puede considerarse como .star-rating
).
Y ahora lo incluiremos en nuestra plantilla de página.
?php /* single-post.php */ ?article ?php /* post content */ ? ?php get_template_part('components/star-rating'); ?/article
Todo el HTML dentro de la plantilla es válido y el navegador lo entiende, excepto star-rating
. Podemos hacer un esfuerzo adicional para solucionar este problema utilizando is
la directiva de Vue:
div is="star-rating" inline-template.../div
Ahora decimos que la calificación máxima no es necesariamente 5, pero es controlable por el editor del sitio web usando Advanced Custom Fields , un popular complemento de WordPress que agrega campos personalizados para páginas, publicaciones y otro contenido de WordPress. Todo lo que necesitamos hacer es inyectar ese valor como accesorio del componente que llamaremos maxRating
:
?php // components/star-rating.php// max_rating is the name of the ACF field$max_rating = get_field('max_rating');?div is="star-rating" inline-template :max-rating="?= $max_rating ?" div pRate recipe:/p button @click="rate(0)" svgpath d="..." :fill="rating === 0 ? 'black' : 'transparent'"/svg /button button v-for="(i in maxRating) @click="rate(i)" svgpath d="..." :fill="rating = i ? 'black' : 'transparent'"/svg /button /div/div
Y en nuestro script, registramos el accesorio y reemplazamos el número mágico 5:
// main.jsVue.component('star-rating', { props: { maxRating: { type: Number, default: 5 // highlight } }, data () { return { rating: 0 } }, methods: { rate (i) { this.rating = i } }, watch: { rating (val) { // prevent rating from going out of bounds by checking it to on every change if (val 0) this.rating = 0 else if (val maxRating) this.rating = maxRating // ... some logic to save to localStorage or somewhere else } }})
Para guardar la calificación de la receta específica, necesitaremos pasar el ID de la publicación. De nuevo, la misma idea:
?php // components/star-rating.php$max_rating = get_field('max_rating');$recipe_id = get_the_ID();?div is="star-rating" inline-template :max-rating="?= $max_rating ?" recipe-id="?= $recipe_id ?" div pRate recipe:/p button @click="rate(0)" svgpath d="..." :fill="rating === 0 ? 'black' : 'transparent'"/svg /button button v-for="(i in maxRating) @click="rate(i)" svgpath d="..." :fill="rating = i ? 'black' : 'transparent'"/svg /button /div/div
// main.jsVue.component('star-rating', { props: { maxRating: { // Same as before }, recipeId: { type: String, required: true } }, // ... watch: { rating (val) { // Same as before // on every change, save to some storage // e.g. localStorage or posting to a WP comments endpoint someKindOfStorageDefinedElsewhere.save(this.recipeId, this.rating) } }, mounted () { this.rating = someKindOfStorageDefinedElsewhere.load(this.recipeId) }})
Ahora podemos incluir el mismo componente de archivo en la página de archivo (un bucle de publicaciones), sin ninguna configuración adicional:
?php // archive.phpif (have_posts()): while ( have_posts()): the_post(); ?article ?php // Excerpt, featured image, etc. then: get_template_part('components/star-rating'); ?/article?php endwhile; endif; ?
El formulario de comentarios
El momento en que un usuario califica una receta es una gran oportunidad para solicitar más comentarios, así que agregamos un pequeño formulario que aparece justo después de establecer la calificación.
// main.jsVue.component('feedback-form', { props: { recipeId: { type: String, required: true }, show: { type: Boolean, default: false } }, data () { return { name: '', subject: '' // ... other form fields } }})
?php // components/feedback-form.php$recipe_id = get_the_ID();?div is="feedback-form" inline-template recipe-id="?= $recipe_id ?" v-if="showForm(recipe-id)" form input type="text" :id="first-name-?= $recipe_id ?" v-model="name" label for="first-name-?= $recipe_id ?"Your name/label ?php /* ... */ ? /form/div
Observe que estamos agregando una cadena única (en este caso recipe-id
) al ID de cada elemento del formulario. Esto es para garantizar que todos tengan identificaciones únicas, incluso si hay varias copias del formulario en la página.
Entonces, ¿dónde queremos que viva esta forma? Necesita conocer la calificación de la receta para saber que necesita abrirse. Solo estamos construyendo buenos componentes, así que usamos la composición para colocar el formulario dentro de star-rating
:
?php // components/star-rating.php$max_rating = get_field('max_rating');$recipe_id = get_the_ID();?div is="star-rating" inline-template :max-rating="?= $max_rating ?" recipe-id="?= $recipe_id ?" div pRate recipe:/p button @click="rate(0)" svgpath d="..." :fill="rating === 0 ? 'black' : 'transparent'"/svg /button button v-for="(i in maxRating) @click="rate(i)" svgpath d="..." :fill="rating = i ? 'black' : 'transparent'"/svg /button ?php get_template_part('components/feedback-form'); ? /div/div
Si en este punto está pensando: “Realmente deberíamos componer ambos componentes en un solo componente principal que maneje el estado de calificación”, entonces concédase 10 puntos y espere pacientemente.
Una pequeña mejora progresiva que podemos agregar para que el formulario sea utilizable sin JavaScript es darle la acción tradicional de PHP y luego anularla en Vue. Lo usaremos @submit.prevent
para evitar la acción original y luego ejecutaremos un submit
método para enviar los datos del formulario en JavaScript.
?php // components/feedback-form.php$recipe_id = get_the_ID();?div is="feedback-form" inline-template recipe-id="?= $recipe_id ?" form action="path/to/feedback-form-handler.php" @submit.prevent="submit" input type="text" :id="first-name-?= $recipe_id ?" v-model="name" label for="first-name-?= $recipe_id ?"Your name/label !-- ... -- /form/div
Entonces, asumiendo que queremos usar fetch
, nuestro submit
método puede ser algo como esto:
// main.jsVue.component('feedback-form', { // Same as before methods: { submit () { const form = this.$el.querySelector('form') const URL = form.action const formData = new FormData(form) fetch(URL, {method: 'POST', body: formData}) .then(result = { ... }) .catch(error = { ... }) } }})
Bien, entonces, ¿qué queremos hacer en .then
y .catch
? Agreguemos un componente que mostrará comentarios en tiempo real sobre el estado de envío del formulario. Primero agreguemos el estado para rastrear el envío, el éxito y el fracaso, y una propiedad calculada que nos indica si estamos pendientes de resultados.
// main.jsVue.component('feedback-form', { // Same as before data () { return { name: '', subject: '' // ... other form fields sent: false, success: false, error: null } }, methods: { submit () { const form = this.$el.querySelector('form') const URL = form.action const formData = new FormData(form) fetch(URL, {method: 'POST', body: formData}) .then(result = { this.success = true }) .catch(error = { this.error = error }) this.sent = true } }})
Para agregar el marcado para cada tipo de mensaje (éxito, fracaso, pendiente), podríamos crear otro componente como los demás que hemos creado hasta ahora. Pero dado que estos mensajes no tienen sentido cuando el servidor muestra la página, es mejor que los mostremos sólo cuando sea necesario. Para hacer esto, colocaremos nuestro marcado en una template
etiqueta HTML nativa, que no muestra nada en el navegador. Luego lo referenciaremos por id como plantilla de nuestro componente.
?php /* components/form-status.php */ ?template v-if="false" div div v-if="pending" img src="?= get_template_directory_uri() ?/spinner.gif" pPatience, young one./p /div div v-else-if="success" img src="?= get_template_directory_uri() ?/beer.gif" pHuzzah!/p /div div v-else-if="error" img src="?= get_template_directory_uri() ?/broken.gif" pOoh, boy. It would appear that: {{ error.text }}/p /div /div/template
¿Por qué agregar v-if="false"
en la parte superior? Es una pequeña cosa complicada. Una vez que Vue recoge el HTML template
, inmediatamente lo considerará como Vue template
y lo renderizará. A menos que, lo hayas adivinado, le digamos a Vuenotto que lo renderice. Un pequeño truco, pero ahí lo tienes.
Como solo necesitamos este marcado una vez en la página, incluiremos el componente PHP en el pie de página.
?php /* footer.php */ ?/div !-- #site-wrapper --?php get_template_part('components/form-status'); ??php wp_footer(); ?
Ahora registraremos el componente con Vue…
// main.jsVue.component('form-status', { template: '#form-status-component' props: { pending: { type: Boolean, required: true }, success: { type: Boolean, required: true }, error: { type: [Object, null], required: true }, }})
…y llámalo dentro de nuestro componente de formulario:
?php // components/feedback-form.php$recipe_id = get_the_ID();?div is="feedback-form" inline-template recipe-id="?= $recipe_id ?" form action="path/to/feedback-form-handler.php" @submit.prevent="submit" input type="text" :id="first-name-?= $recipe_id ?" v-model="name" label for="first-name-?= $recipe_id ?"Your name/label ?php // ... ? /form form-status v-if="sent" :pending="pending" :success="success" :error="error" //div
Dado que nos registramos form-status
usando Vue.component
, está disponible globalmente, sin incluirlo específicamente en el directorio principal components: { }
.
Filtrar recetas
Ahora que los usuarios pueden personalizar algunas partes de su experiencia en nuestro blog, podemos agregar todo tipo de funciones útiles. Específicamente, permitamos a los usuarios establecer una calificación mínima que quieran ver, usando una entrada en la parte superior de la página.
Lo primero que necesitamos es algún estado global para rastrear la calificación mínima establecida por el usuario. Dado que comenzamos inicializando una aplicación Vue en toda la página, el estado global serán solo datos en la instancia de Vue:
// main.js// Same as beforenew Vue({ el: document.getElementById('site-wrapper'), data: { minimumRating: 0 }})
¿Y dónde podemos poner los controles para cambiar esto? Dado que toda la página es la aplicación, la respuesta es casi cualquier lugar. Por ejemplo, en la parte superior de la página de archivo:
?php /* archive.php */ ?label for="minimum-rating-input"Only show me recipes I've rated at or above:/labelinput type="number" v-model="minimumRating"?php if (have_posts()): while ( have_posts()): the_post(); ?article ?php /* Post excerpt, featured image, etc. */ ? ?php get_template_part('components/star-rating'); ?/article?php endwhile; endif; ?
Mientras esté dentro de nuestro site-wrapper
componente y no dentro de otro componente, simplemente funcionará. Si queremos, también podríamos construir un componente de filtrado que cambiaría el estado global. Y si quisiéramos ser más sofisticados, incluso podríamos agregar Vuex a la mezcla (dado que Vuex no puede persistir el estado entre páginas de forma predeterminada, podríamos agregar algo como vuex-persist para usar localStorage
).
Entonces, ahora necesitamos ocultar o mostrar una receta según el filtro. Para hacer esto, necesitaremos envolver el contenido de la receta en su propio componente, con una v-show
directiva. Probablemente sea mejor usar el mismo componente tanto para la página única como para la página de archivo. Desafortunadamente, ninguno de los require
dos get_template_part
puede pasar parámetros al archivo llamado, pero podemos usar global
variables:
?php /* archive.php */ ?label for="minimum-rating-input"Only show me recipes I've rated at or above:/labelinput type="number" v-model="minimumRating"?php $is_archive_item = true;if (have_posts()): while ( have_posts()): the_post(); get_template_part('components/recipe-content');endwhile; endif; ?
Luego podemos usarlo $is_archive_item
como global
variable dentro del archivo del componente PHP para verificar si está configurado y true
. Como no necesitaremos ocultar el contenido en la página de publicación única, agregaremos la v-show
directiva de manera condicional.
?php // components/recipe-content.phpglobal $is_archive_item; ?div is="recipe-content" article ?php if ($is_archive_item): ? v-show="show" ?php endif; ? ?php if ($is_archive_item): the_excerpt(); else the_content(); endif; get_template_part('components/star-rating'); ? /article/div
En este ejemplo específico, también podríamos haber probado is_archive()
dentro del componente, pero en la mayoría de los casos necesitaremos configurar accesorios explícitos.
Necesitaremos mover el rating
estado y la lógica al recipe-content
componente para que pueda saber si necesita ocultarse. En el interior star-rating
, haremos una costumbre v-model
reemplazando calificación value
con y this.rating = i
con $emit('input', i)
también. Entonces nuestro registro de componentes ahora se verá así:
// main.jsVue.component('recipe-content', { data () { rating: 0 }, watch: { rating (val) { // ... } }, mounted () { this.rating = someKindOfStorageDefinedElsewhere.load(this.recipeId) }})Vue.component('star-rating', { props: { maxRating: { /* ... */ }, recipeId: { /* ... */ }, value: { type: Number, required: true } }, methods: { rate (i) { this.$emit('input', i) } },})
Agregaremos y v-model
cambiaremos a . Además, ahora podemos subir a :star-rating.php
rating
value
feedback-form
recipe-content
?php // components/star-rating.php$max_rating = get_field('max_rating');$recipe_id = get_the_ID();?div is="star-rating" inline-template :max-rating="?= $ max_rating ?" recipe-id="?= $recipe_id ?" v-model="value" div pRate recipe:/p button @click="rate(0)" svgpath d="..." :fill="value === 0 ? 'black' : 'transparent'"/svg /button button v-for="(i in maxRating) @click="rate(i)" svgpath d="..." :fill="value = i ? 'black' : 'transparent'"/svg /button /div/div
?php // components/recipe-content.phpglobal $is_archive_item; ?div is="recipe-content" article ?php if ($is_archive_item): ? v-show="show" ?php endif; ? ?php if ($is_archive_item): the_excerpt(); else the_content(); endif; get_template_part('components/star-rating'); get_template_part('components/feedback-form'); ? /article/div
Ahora todo está configurado para que el renderizado inicial muestre todas las recetas y luego el usuario pueda filtrarlas según su calificación. En el futuro, podríamos agregar todo tipo de parámetros para filtrar contenido. Y no tiene que basarse en la entrada del usuario: podemos permitir el filtrado según el contenido en sí (por ejemplo, número de ingredientes o tiempo de cocción) pasando los datos de PHP a Vue.
Conclusión
Bueno, fue un camino un poco largo, pero mira lo que hemos creado: componentes independientes, componibles, mantenibles, interactivos y mejorados progresivamente en nuestro tema de WordPress . ¡Reunimos lo mejor de todos los mundos!
He estado usando este enfoque en producción desde hace un tiempo y me encanta la forma en que me permite razonar sobre las diferentes partes de mis temas. Espero haberte inspirado a probarlo también.
- Por supuesto, dos días antes del lanzamiento, el departamento legal del cliente decidió que no quería recopilar toda esa información. Actualmente, la forma viva no es más que una sombra de su yo en desarrollo.
- Dato curioso: Rasmus Lerdorf dijo que su intención original era que PHP fuera solo plantillas, con toda la lógica de negocios manejada en C. Dejemos que esto se asimile por un momento. Luego, libera una hora de tu agenda y mira la charla completa.
- Existen motores de plantillas de WordPress de terceros que pueden compilarse en PHP optimizado. Me viene a la mente Twig , por ejemplo. Estamos intentando ir por la ruta inversa y enviar PHP básico para que sea manejado por JavaScript.
Deja un comentario