كيف جعلت التطوير على Vue.js مناسبًا مع التقديم من جانب الخادم

مرحبا بالجميع!

سأبدأ بخلفية صغيرة.

قررت تجربة مشروعي الجديد على Vue.js. كنت بحاجة إلى تقديم جانب الخادم (SSR) ، ووحدات CSS ، وتقسيم الشفرة ، وغيرها من الأشياء الجيدة. بالطبع ، هناك حاجة إلى إعادة تشغيل ساخنة (HMR) لزيادة إنتاجية التطوير.

لم أكن أرغب في استخدام حلول جاهزة ، مثل Nuxt.js ، لأن عندما ينمو المشروع ، من المهم أن تكون قادرًا على التخصيص. وأي حلول عالية المستوى ، كقاعدة عامة ، لا تسمح بالقيام بذلك ، أو بالتبرع ، ولكن بجهود كبيرة (كانت هناك تجربة مماثلة باستخدام Next.js لـ React).

كانت المشكلة الرئيسية للتنمية المحلية عند استخدام تجسيد الخادم وإعادة التشغيل الساخنة أنه لا يكفي تشغيل خادم webpack-dev واحد . يجب أيضًا أن نفعل شيئًا باستخدام المصادر التي تطلقها Node.js ، وإلا في المرة القادمة التي نقوم فيها بإعادة تحميل الصفحة ، سنحصل على رمز لم يتم تحديثه على الخادم ولكن تم تحديثه على العميل.

بعد أن غرقت في الوثائق والإنترنت ، لم أجد للأسف أمثلة وقوالب جاهزة للعمل. لذلك ، أنا خلقت بلدي.



لقد حددت ما يجب أن يتكون عليه القالب الخاص بك بحيث يمكنك قيادة عملية تطوير مريحة:

  • VueJS
  • SSR
  • Vuex
  • وحدات CSS
  • تقسيم الرمز
  • ESLint ، أجمل

مع التطوير المحلي ، يجب تحديث كل هذا في المتصفح أثناء التنقل ، كما يجب تحديث رمز الخادم.

في وضع الإنتاج ، يجب تصغير الحزم ، ويجب إضافة علامة تجزئة للتخزين المؤقت للإحصائيات ، ويجب تعيين المسارات إلى الحزم تلقائيًا في قالب html.

كل هذا يتم تنفيذه في المستودع على جيثب ، سأقدم الكود وأصف الحلول.

تجدر الإشارة إلى أن Vue.js لديها وثائق شاملة تمامًا لإعداد تقديم الخادم ، لذلك فمن المنطقي أن ننظر هناك.

جانب الخادم


لذلك ، سوف نستخدم Express كخادم لـ Node.js ، كما نحتاج أيضًا إلى vue-server-renderer . تتيح لنا هذه الحزمة تحويل الشفرة إلى سلسلة html ، استنادًا إلى حزمة الخادم وقوالب html وبيان العميل ، حيث تتم الإشارة إلى أسماء الموارد ومسارها.

سيبدو ملف server.js في النهاية كالتالي:

const path = require('path'); const express = require('express'); const { createBundleRenderer } = require('vue-server-renderer'); const template = require('fs').readFileSync( path.join(__dirname, './templates/index.html'), 'utf-8', ); const serverBundle = require('../dist/vue-ssr-server-bundle.json'); const clientManifest = require('../dist/vue-ssr-client-manifest.json'); const server = express(); const renderer = createBundleRenderer(serverBundle, { //           ,     runInNewContext: false, template, clientManifest, inject: false, }); //         nginx server.use('/dist', express.static(path.join(__dirname, '../dist'))); server.get('*', (req, res) => { const context = { url: req.url }; renderer.renderToString(context, (err, html) => { if (err) { if (+err.message === 404) { res.status(404).end('Page not found'); } else { console.log(err); res.status(500).end('Internal Server Error'); } } res.end(html); }); }); server.listen(process.env.PORT || 3000); 

كما ترون ، نستخدم ملفين: vue-ssr-server-bundle.json و vue-ssr-client-manifest.json .

يتم إنشاؤها عند إنشاء التطبيق. في الأول هو الرمز الذي سيتم تنفيذه على الخادم ، والثاني يحتوي على أسماء ومسارات الموارد.

أيضًا ، في خيارات createBundleRenderer ، حددنا inject: false معلمة. هذا يعني أنه لن يكون هناك إنشاء تلقائي لرمز html لتحميل الموارد وأشياء أخرى ، لأنه نحن بحاجة إلى مرونة كاملة. في القالب ، سنضع علامة بشكل مستقل على الأماكن التي نريد عرض هذا الرمز فيها.

سيبدو القالب نفسه كالتالي:

 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> {{{ meta.inject().title.text() }}} {{{ meta.inject().meta.text() }}} {{{ renderResourceHints() }}} {{{ renderStyles() }}} </head> <body> <div id="app"><!--vue-ssr-outlet--></div> {{{ renderState() }}} {{{ renderScripts() }}} </body> </html> 

دعنا نفكر بمزيد من التفاصيل.

  • هناك حاجة إلى meta.inject (). title.text () و meta.inject (). meta.text () لعرض الرؤوس وأوصاف التعريف. حزمة vue-meta هي المسؤولة عن هذا الأمر ، والتي سأناقشها أدناه
  • renderResourceHints () - سيعود ارتباطات rel = "preload / prefetch" إلى الموارد المحددة في بيان العميل
  • renderStyles () - سيعود الروابط إلى الأنماط المحددة في بيان العميل
  • renderState () - سيعود الحالة الافتراضية في النافذة .__ INITIAL_STATE__
  • renderScripts () - سيعود البرامج النصية اللازمة للتطبيق للعمل

بدلاً من التعليق ، سيتم استبدال ترميز تطبيقنا. هو مطلوب.

نقطة الدخول إلى تطبيق جانب خادم Vue هي ملف entry-server.js .

 import { createApp } from './app'; export default context => new Promise((resolve, reject) => { //      Vue const { app, router, store } = createApp(); // $meta - ,   vue-meta   Vue const meta = app.$meta(); //      router.push(context.url); //  -  ,      context.meta = meta; router.onReady(() => { context.rendered = () => { //    ,     ,  window.__INITIAL_STATE__ context.state = store.state; }; const matchedComponents = router.getMatchedComponents(); //     if (!matchedComponents.length) { return reject(new Error(404)); } return resolve(app); }, reject); }); 

جزء العميل


نقطة الإدخال من جانب العميل هي ملف الإدخال client.js .

 import { createApp } from './app'; const { app, router, store } = createApp(); router.onReady(() => { if (window.__INITIAL_STATE__) { //    ,     store.replaceState(window.__INITIAL_STATE__); } app.$mount('#app'); }); //    HMR  ,  webpack-dev-server     hot if (module.hot) { const api = require('vue-hot-reload-api'); const Vue = require('vue'); api.install(Vue); if (!api.compatible) { throw new Error( 'vue-hot-reload-api is not compatible with the version of Vue you are using.', ); } module.hot.accept(); } 

في app.js ، يتم إنشاء مثيل Vue الخاص بنا ، والذي يتم استخدامه بعد ذلك على كل من الخادم والعميل.

 import Vue from 'vue'; import { sync } from 'vuex-router-sync'; import { createRouter } from './router'; import { createStore } from './client/store'; import App from './App.vue'; export function createApp() { const router = createRouter(); const store = createStore(); sync(store, router); const app = new Vue({ router, store, render: h => h(App), }); return { app, router, store }; } 

نقوم دائمًا بإنشاء مثيل جديد لتجنب موقف تستخدم فيه طلبات متعددة نفس المثيل.

App.vue هو المكون الجذر ، والذي يحتوي على التوجيه <router-view> </router-view> ، والذي سيحل محل المكونات الضرورية ، اعتمادًا على المسار.

جهاز التوجيه نفسه يشبه هذا

 import Vue from 'vue'; import Router from 'vue-router'; import VueMeta from 'vue-meta'; import routes from './routes'; Vue.use(Router); Vue.use(VueMeta); export function createRouter() { return new Router({ mode: 'history', routes: [ { path: routes.pages.main, component: () => import('./client/components/Main.vue') }, { path: routes.pages.about, component: () => import('./client/components/About.vue') }, ], }); } 

من خلال Vue.use نربط اثنين من المكونات الإضافية: راوتر و VueMeta .
في المسارات ، لا يتم الإشارة إلى المكونات نفسها مباشرة ، ولكن من خلال

 () => import('./client/components/About.vue') 

هذا هو تقسيم الرمز.

بالنسبة لإدارة الدولة (التي تنفذها شركة Vuex) ، فإن تكوينها ليس شيئًا مميزًا. الشيء الوحيد هو ، لقد قسمت الجانب إلى وحدات واستخدمت الثوابت التي تحمل اسمًا لتسهيل التنقل بواسطة الكود.

الآن النظر في بعض الفروق الدقيقة في مكونات Vue أنفسهم.

تكون خاصية metaInfo مسؤولة عن تقديم بيانات التعريف باستخدام حزمة التعريف - vue . يمكنك تحديد عدد كبير من المعلمات المختلفة ( أكثر ).

 metaInfo: { title: 'Main page', } 

تحتوي المكونات على طريقة تعمل فقط على جانب الخادم.

 serverPrefetch() { console.log('Run only on server'); } 

أيضا ، كنت أرغب في استخدام وحدات CSS. تعجبني الفكرة عندما لا تضطر إلى الاهتمام باسم الفئات حتى لا تتداخل بين المكونات. باستخدام وحدات CSS ، ستبدو الفئة الناتجة <اسم الفئة> _ <هش> .

للقيام بذلك ، تحتاج إلى تحديد وحدة نمطية في المكون.

 <style module> .item { padding: 3px 0; } .controls { margin-top: 12px; } </style> 

وفي القالب حدد السمة : class

 <div :class="$style.item"></div> 

أيضًا ، من الضروري أن تحدد في إعدادات حزمة الويب أننا سنستخدم الوحدات.

جمعية


دعنا ننتقل إلى إعدادات webpack أنفسهم.

لدينا التكوين الأساسي الذي ورثته التكوين للخادم وأجزاء العميل.

 const webpack = require('webpack'); const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); const merge = require('webpack-merge'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); const isProduction = process.env.NODE_ENV === 'production'; let config = { mode: isProduction ? 'production' : 'development', module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', }, { test: /\.js$/, loader: 'babel-loader', exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file), }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, use: { loader: 'url-loader', options: { limit: 10000, name: 'images/[name].[hash:8].[ext]', }, }, }, ], }, plugins: [ new VueLoaderPlugin(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), }), ], }; if (isProduction) { config = merge(config, { optimization: { minimizer: [new OptimizeCSSAssetsPlugin(), new UglifyJsPlugin()], }, }); } module.exports = config; 

لا يختلف التكوين الخاص ببناء رمز خادم عن التكوين الموجود في الوثائق . باستثناء التعامل مع CSS.

 const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); const baseConfig = require('./webpack.base.js'); module.exports = merge(baseConfig, { entry: './app/entry-server.js', target: 'node', devtool: 'source-map', output: { libraryTarget: 'commonjs2', }, externals: nodeExternals({ whitelist: /\.css$/, }), plugins: [new VueSSRServerPlugin()], module: { rules: [ { test: /\.css$/, loader: 'css-loader', options: { modules: { localIdentName: '[local]_[hash:base64:8]', }, }, }, ], }, }); 

في البداية ، تم نقل كل معالجة CSS إلى التكوين الأساسي ، لأن هناك حاجة سواء على العميل وعلى الخادم. كان هناك تصغير لنظام الإنتاج.
ومع ذلك ، واجهت مشكلة تحول فيها الخادم إلى وثيقة ، وبالتالي حدث خطأ. تبين أن هذا خطأ في mini-css-extract-plugin تم إصلاحه عن طريق تقسيم معالجة CSS للخادم والعميل.

ينشئ VueSSRServerPlugin ملف vue-ssr-server-bundle.json ، والذي يشير إلى الكود الذي يتم تشغيله على الخادم.

الآن النظر في التكوين العميل.

 const webpack = require('webpack'); const merge = require('webpack-merge'); const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const path = require('path'); const baseConfig = require('./webpack.base.js'); const isProduction = process.env.NODE_ENV === 'production'; let config = merge(baseConfig, { entry: ['./app/entry-client.js'], plugins: [new VueSSRClientPlugin()], output: { path: path.resolve('./dist/'), filename: '[name].[hash:8].js', publicPath: '/dist/', }, module: { rules: [ { test: /\.css$/, use: [ isProduction ? MiniCssExtractPlugin.loader : 'vue-style-loader', { loader: 'css-loader', options: { modules: { localIdentName: '[local]_[hash:base64:8]', }, }, }, ], }, ], }, }); if (!isProduction) { config = merge(config, { output: { filename: '[name].js', publicPath: 'http://localhost:9999/dist/', }, plugins: [new webpack.HotModuleReplacementPlugin()], devtool: 'source-map', devServer: { writeToDisk: true, contentBase: path.resolve(__dirname, 'dist'), publicPath: 'http://localhost:9999/dist/', hot: true, inline: true, historyApiFallback: true, port: 9999, headers: { 'Access-Control-Allow-Origin': '*', }, }, }); } else { config = merge(config, { plugins: [ new MiniCssExtractPlugin({ filename: '[name].[hash:8].css', }), ], }); } module.exports = config; 

من الجدير بالذكر ، في التنمية المحلية ، نقوم بتحديد publicPath ، والرجوع إلى webpack-dev-server ، وإنشاء اسم الملف بدون تجزئة. أيضًا ، بالنسبة لـ devServer ، نحدد معلمة writeToDisk: true .

هناك حاجة إلى شرح هنا.

بشكل افتراضي ، يقوم webpack-dev-server بتوزيع الموارد من ذاكرة الوصول العشوائي دون كتابتها على القرص. في هذه الحالة ، نواجه مشكلة في بيان العميل ( vue-ssr-client-manifest.json ) ، الموجود على القرص ، سيتم الإشارة إلى موارد غير ملائمة ، لأنه لن يتم تحديثه. للتغلب على ذلك ، نطلب من خادم dev كتابة التغييرات على القرص ، وفي هذه الحالة سيتم تحديث بيان العميل وسيتم تشديد الموارد اللازمة.

في الحقيقة ، في المستقبل أريد أن أتخلص من هذا. حل واحد هو في البكر. وضع في server.js لتوصيل البيان ليس من دليل / dist ، ولكن من عنوان url الخاص بالخادم. لكن في هذه الحالة ، تصبح عملية غير متزامنة. سأكون سعيدًا بحل لطيف للمشكلة في التعليقات.

Nodemon مسؤول عن إعادة تحميل جانب الخادم ، الذي يراقب ملفين: dist / vue-ssr-server-bundle.json و app / server.js ويعيد تشغيل التطبيق عند تغييرهما.

لتتمكن من إعادة تشغيل التطبيق عند تغيير server.js ، لا نحدد هذا الملف كنقطة إدخال في nodemon ، ولكننا ننشئ ملف nodemon.js الذي نربط فيه server.js . ويصبح الملف nodemon.js نقطة الدخول.

في وضع الإنتاج ، يصبح التطبيق / server.js نقطة الدخول.

استنتاج


المجموع ، لدينا مستودع مع الإعدادات والعديد من الفرق.

للتنمية المحلية:

 yarn run dev 

من جانب العميل: يقوم بإطلاق خادم webpack-dev ، الذي يراقب التغييرات في مكونات Vue والرمز البسيط ، ويقوم بإنشاء بيان عميل مع مسارات لخادم dev ، ويحفظه على القرص ويقوم بتحديث الكود ، والأنماط أثناء التنقل في المتصفح.

من جانب الخادم: يبدأ تشغيل حزمة الويب في وضع المراقبة ، ويقوم بجمع حزمة الخادم ( vue-ssr-server-bundle.json ) وإعادة تشغيل التطبيق عند تغييره.

في هذه الحالة ، يتغير الرمز باستمرار على العميل والخادم تلقائيًا.
في البداية ، قد يحدث خطأ أنه لم يتم العثور على حزمة الخادم. هذا طبيعي. فقط تحتاج إلى إعادة تشغيل الأمر.

لإنتاج التجميع:

 yarn run build 

على جانب العميل: يجمع js و css ويصغرهما ، ويضيف علامة تجزئة إلى الاسم وينشئ بيانًا عميلًا مع مسارات موارد نسبيّة.

من جانب الخادم: يجمع حزمة الخادم.

أيضًا ، قمت بإنشاء أمر start-node لتشغيل الغزل ، والذي يبدأ بـ server.js ، ولكن يتم ذلك فقط كمثال ، في تطبيق الإنتاج لبدء تشغيله ، يستحق استخدام مديري العمليات ، على سبيل المثال ، PM2.

آمل أن تساعد التجربة الموضحة في إعداد النظام البيئي بسرعة للعمل المريح والتركيز على تطوير الوظيفة.

روابط مفيدة


Source: https://habr.com/ru/post/ar458396/


All Articles