Tu hogar de recursos

Encuentra plantillas web, plugins y más …

así mantuvimos un alto rendimiento en nuestra aplicación de desarrollo local para una experiencia de usuario mejorada – WordPress.com en Español


En este artículo compartimos las estrategias y técnicas que hemos implementado para garantizar que Studio, nuestra app basada en Electron, ofrezca el mejor rendimiento para la experiencia de usuario.

¡Hemos vuelto con la segunda parte de nuestra serie «El desarrollo de Studio en abierto»! Hoy, veremos los retos que tuvimos que afrontar para optimizar el rendimiento de Studio. Este artículo puede ser especialmente interesante si estás desarrollando una aplicación con Electron y te encuentras con problemas de rendimiento, o si tienes curiosidad por cómo funcionan los entresijos de la aplicación Studio.

Si te interesa la temática de esta serie, no te pierdas nuestro primer artículo: Utilizando los componentes de WordPress y Tailwind CSS en nuestra aplicación de desarrollo local.

Recuerda: Studio es nuestra app gratuita de código abierto para desarrollar de forma local. Está basada en Electron (¡el tema del artículo de hoy!) y se puede descargar en Mac y Windows.

El reto de ejecutar sitios de desarrollo de forma local

Ejecutar un sitio de desarrollo local puede ser bastante complejo, y a veces es necesario configurar varias herramientas. La estrategia habitual suele ser utilizar aplicaciones de varios contenedores como Docker o Docker Compose, además de crear un servidor web con una instalación de WordPress y una base de datos MySQL. Este proceso puede complicarse aún más al gestionar varios sitios simultáneamente.

Studio ha sido diseñado para simplificar este proceso, de forma que los usuarios pueden crear sitios rápidamente sin ninguna configuración inicial. Esto es gracias al proyecto WordPress Playground, que permite que cualquier persona pueda ejecutar un sitio de WordPress totalmente funcional desde un navegador o un entorno Node.js.

Por cada sitio creado con Studio, tenemos un servidor web básico con ExpressJS para gestionar las solicitudes web y utilizamos Playground para procesarlas.

Al principio, implementamos todo esto en Studio, nuestra app basada en Electron, sin ningún problema de rendimiento a simple vista.

A gif of someone squinting their eyes

Sin embargo, a medida que hacíamos nuestras pruebas en Mac y Windows, observamos cierta lentitud en algunas interacciones con la UI al gestionar y navegar por los sitios. Todo parecía estar configurado correctamente, pero estaba claro que algo no iba del todo bien.

Mantener ligero el proceso principal

Mientras indagábamos sobre estos problemas de rendimiento, descubrimos que la causa principal de esta lentitud era que los sitios se ejecutaban en el proceso principal de Electron. Procesar las solicitudes web y ejecutar el código PHP asociado a WordPress en el proceso principal le añadía mucha carga, que afectaba negativamente a otras operaciones, de ahí la carga lenta de la UI.

Capturas de pantalla donde se muestra la diferencia entre empezar y crear un sitio en el proceso principal de Electron frente a hacerlo en un proceso dedicado.

La documentación de Electron es muy útil para resolver problemas de rendimiento, sobre todo aquellos relacionados a bloquear el proceso principal. Está claro que conseguir que el proceso principal siga siendo ligero es esencial, así como evitar procesos pesados o que puedan causar bloqueos en este contexto. Sin embargo, esto planteaba un nuevo desafío: ¿cómo separábamos la ejecución de los sitios del proceso principal?

Creación de procesos dedicados

Para afrontar los problemas de rendimiento, utilizamos la estrategia del «divide y vencerás».

La idea era ejecutar los sitios de Studio en procesos dedicados separados del principal. Electron está construido en Node.js, así que crear procesos hijo parecía una solución viable. Además, Electron también ofrece la utilidad utilityProcess, que tiene un comportamiento similar a los procesos hijo de Node, pero opera en el navegador y se parece más al modelo de app de Electron.

Aunque esta estrategia prometía aliviar la carga en el proceso principal, también introdujo una capa más de complejidad. Teníamos que gestionar tanto estos nuevos procesos como los mensajes de comunicación entre el proceso principal y los procesos dedicados. Además, encontramos nuevos problemas en cuanto a la configuración del empaquetado y el hecho de utilizar Webpack para crear la aplicación.

Aquí puedes ver un ejemplo completo de cómo implementamos esta estrategia (haz clic para expandir y ver el código completo de cada ejemplo):

Gestión del proceso dedicado (process.js):
const { app, utilityProcess } = require( 'electron' );
 
// This path should be calculated dynamically as the file could be in
// different locations depending on the build configuration
const PROCESS_MODULE_PATH = './process-child.js';
 
const DEFAULT_RESPONSE_TIMEOUT = 120000;
 
class Process {
    lastMessageId = 0;
    process;
    ongoingMessages = {};
 
    async init() {
        return new Promise( ( resolve, reject ) => {
            const spawnListener = async () => {
                // Removing exit listener as we only need it upon starting
                this.process?.off( 'exit', exitListener );
                resolve();
            };
            const exitListener = ( code ) => {
                if ( code !== 0 ) {
                    reject( new Error( `process exited with code ${ code } upon starting` ) );
                }
            };
 
            this.process = utilityProcess
                .fork( PROCESS_MODULE_PATH, [], {
                    serviceName: 'dedicated-process',
                    env: {
                        ...process.env,
                        IN_CHILD_PROCESS: 'true',
                        APP_NAME: app.name,
                        // Note that Electron context won't be available in the dedicated process.
                        // Add here other environment variables that might be needed.
                    },
                } )
                .on( 'spawn', spawnListener )
                .on( 'exit', exitListener );
        } );
    }
 
    // This is an example function. Feel free to add more for other purposes.
    async exampleFunc( command, args ) {
        const message = 'exampleFunc';
        const messageId = this.sendMessage( message, { command, args } );
        return await this.waitForResponse( message, messageId );
    }
 
    // It's important to keep in mind that the process will be running
    // until it's explicitly stopped.
    async stop() {
        await this.killProcess();
    }
 
    sendMessage( message, data ) {
        const process = this.process;
        if ( ! process ) {
            throw Error( 'The process is not running' );
        }
 
        const messageId = this.lastMessageId++;
        process.postMessage( { message, messageId, data } );
        return messageId;
    }
 
    async waitForResponse( originalMessage, originalMessageId, timeout = DEFAULT_RESPONSE_TIMEOUT ) {
        const process = this.process;
        if ( ! process ) {
            throw Error( 'The process is not running' );
        }
        if ( this.ongoingMessages[ originalMessageId ] ) {
            throw Error(
                `The 'waitForResponse' function was already called for message ID ${ originalMessageId } from the message '${ originalMessage }'. 'waitForResponse' may only be called once per message ID.`
            );
        }
 
        return new Promise( ( resolve, reject ) => {
            const handler = ( { message, messageId, data, error } ) => {
                if ( message !== originalMessage || messageId !== originalMessageId ) {
                    return;
                }
                process.removeListener( 'message', handler );
                clearTimeout( timeoutId );
                delete this.ongoingMessages[ originalMessageId ];
                if ( typeof error !== 'undefined' ) {
                    console.error( error );
                    reject( new Error( error ) );
                    return;
                }
                resolve( data );
            };
 
            const timeoutHandler = () => {
                reject( new Error( `Request for message ${ originalMessage } timed out` ) );
                process.removeListener( 'message', handler );
            };
            const timeoutId = setTimeout( timeoutHandler, timeout );
            const cancelHandler = () => {
                clearTimeout( timeoutId );
                reject( {
                    error: new Error( `Request for message ${ originalMessage } was canceled` ),
                    canceled: true,
                } );
                process.removeListener( 'message', handler );
            };
            this.ongoingMessages[ originalMessageId ] = { cancelHandler };
 
            process.addListener( 'message', handler );
        } );
    }
 
    async killProcess() {
        const process = this.process;
        if ( ! process ) {
            throw Error( 'The process is not running' );
        }
 
        this.cancelOngoingMessages();
 
        return new Promise( ( resolve, reject ) => {
            process.once( 'exit', ( code ) => {
                if ( code !== 0 ) {
                    reject( new Error( `Process exited with code ${ code } upon stopping` ) );
                    return;
                }
                resolve();
            } );
            process.kill();
        } ).catch( ( error ) => {
            console.error( error );
        } );
    }
 
    cancelOngoingMessages() {
        Object.values( this.ongoingMessages ).forEach( ( { cancelHandler } ) => {
            cancelHandler();
        } );
    }
}
 
module.exports = Process;
Lógica del proceso dedicado (process-child.js):
// Replace with initial setup logic based on the environment variables if needed.
console.log( `Run initial setup for app: ${ process.env.APP_NAME }` );
 
const handlers = {
    exampleFunc: createHandler( exampleFunc ),
};
 
async function exampleFunc( data ) {
    const { command, args } = data;
    // Replace this with the desired logic.
    console.log( `Run heavy operation ${ command } with args: ${ args }` );
}
 
function createHandler( handler ) {
    return async ( message, messageId, data ) => {
        try {
            const response = await handler( data );
            process.parentPort.postMessage( {
                message,
                messageId,
                data: response,
            } );
        } catch ( error ) {
            process.parentPort.postMessage( {
                message,
                messageId,
                error: error?.message || 'Unknown Error',
            } );
        }
    };
}
 
process.parentPort.on( 'message', async ( { data: messagePayload } ) => {
    const { message, messageId, data } = messagePayload;
    const handler = handlers[ message ];
    if ( ! handler ) {
        process.parentPort.postMessage( {
            message,
            messageId,
            error: Error( `No handler defined for message '${ message }'` ),
        } );
        return;
    }
    await handler( message, messageId, data );
} );
Ejecución de ejemplo (main.js):
async function runExample() {
    const process = new Process();
    await process.init();
    await process.exampleFunc( 'my-command', [ 'example', 100 ] );
}
 
…
app.whenReady().then( () => {
    runExample();
} );
…

Nota: Este código está adaptado para su uso en un proyecto de Electron genérico de ejemplo. Puedes probarlo con el Electron Fiddle.

Configuración de la compilación y Webpack

La compilación de nuestro proyecto se basa en Forge y Webpack. Al implementar los procesos dedicados se introdujo más complejidad, ya que al principio incluimos todo el código en un solo archivo.

Pero, debido a que los procesos dedicados requieren que su código se ejecute de forma aislada del proceso principal, era necesario que separásemos los paquetes. Una vez ajustada la configuración de Webpack, conseguimos producir los archivos necesarios. Échale un vistazo a un ejemplo de los cambios que introdujimos (haz clic para expandir y ver el código completo de cada ejemplo):

Antes:
import { type Configuration } from 'webpack';
 
export const mainConfig: Configuration = {
    // This is the main entry point for your application, it's the first file
    // that runs in the main process.
    entry: './src/index.ts',
 
...
Después:
import path from 'path';
import { type Configuration, DefinePlugin } from 'webpack';
 
// Extra entries are bundled separately from the main bundle. They are primarily used
// for worker threads and forked processes, which need to be loaded independently.
const extraEntries = [
    {
        name: 'siteServerProcess',
        path: './src/lib/site-server-process-child.ts',
        exportName: 'SITE_SERVER_PROCESS_MODULE_PATH',
    },
    // Here you can configure other dedicated processes
];
 
export default function mainConfig( _env: unknown, args: Record ) {
    const isProduction = args.mode === 'production';
 
    // Generates the necessary plugins to expose the module path of extra entries.
    const definePlugins = extraEntries.map( ( entry ) => {
        // The path calculation is based on how the Forge's webpack plugin generates the path for Electron files.
        // Reference: https://github.com/electron/forge/blob/b298b2967bdc79bdc4e09681ea1ccc46a371635a/packages/plugin/webpack/src/WebpackConfig.ts#L113-L140
        const modulePath = isProduction
            ? `require('path').resolve(__dirname, '..', 'main', '${ entry.name }.js')`
            : JSON.stringify( path.resolve( __dirname, `.webpack/main/${ entry.name }.js` ) );
        return new DefinePlugin( {
            [ entry.exportName ]: modulePath,
        } );
    } );
 
    return {
        ...mainBaseConfig,
        plugins: [ ...( mainBaseConfig.plugins || [] ), ...definePlugins ],
    };
}
 
export const mainBaseConfig: Configuration = {
    entry: {
        // This is the main entry point for your application, it's the first file
        // that runs in the main process.
        index: './src/index.ts',
        // Inject extra entries into the Webpack configuration.
        // These entries are primarily used for worker threads and forked processes.
        ...extraEntries.reduce( ( accum, entry ) => {
            return { ...accum, [ entry.name ]: entry.path };
        }, {} ),
    },
 
...

Nota: Este código está tomado directamente de Studio y está escrito con TypeScript.

Un consejo más: evita bloquear las operaciones del sistema de archivos

También notamos problemas de rendimiento en las operaciones del sistema de archivos al crear Studio, sobre todo al utilizar versiones síncronas de las funciones, ya que pueden bloquear el proceso principal. Para evitarlo, lo mejor es usar versiones basadas en promesas o callback de estas funciones.

Por ejemplo, en lugar de usar:

fs.readFileSync( path, 'utf8');

Utiliza:

await fs.readFile( path, 'utf8');

¿Todo listo para empezar a construir?

Si te ha parecido interesante esta información o si desarrollas sitios de WordPress, no dejes pasar el potencial de Studio. Es gratuito, de código abierto y se integra a la perfección en tus procesos de desarrollo.

Cuando hayas descargado Studio, conéctalo a tu cuenta de WordPress.com (gratis o de pago) para tener acceso a otras funciones como los sitios de demostración.

¿Quieres colaborar con Studio? Estos son algunos issues de GitHub con los que puedes echar una mano:


Únete a otros 9M suscriptores



Fuente Original:

Compartir: