Final solution

The final solution for multi-page application project architecture has been released

Egg version is an upgrade based on webPack4 + EJS + EGG multi-page application project. The robustness and scalability of the server have been improved.

preface

GitHub full project address

Recently, I accepted a project on the company’s official website, which needs SEO friendly, so I can’t use the front-end frame, and the scaffolding tools that come with the front-end frame naturally can’t help. We had to use Webpack4 + EJS + Express to build a multi-page application project architecture from scratch. During the process of building, I encountered many pits, but there were very few references on the Internet, so I wrote a blog to record the process of building and precautions.

Below I will mark the important details in red for the reference of friends who need them.

“Isomorphism” or “server-side rendering”?

After this post was published, a friend asked in the comments section why not just use some isomorphic framework like NextJS or NuxtJS? This problem may also be one of the more tangled problems in our development, let me tell you my own ideas.

homogeneous

In fact, the so-called “isomorphism” is just when loading the first screen of the web page, using server-side rendering, by the server to parse the VDOM to generate the real DOM and then return. By the time the first screen of code loads and the front-end framework takes over the browser, the entire process is client-side rendering, not server-side.

advantages

  • The front-end framework can be used to organize the page structure in a “component” way, which is more flexible and reusable
  • Backed by the front – end framework of the three giants, a good ecosystem of plug-in components
  • The subsequent page switching is smooth and the user experience is good
  • Convenient data operation and data sharing, suitable for complex service scenarios or scenarios requiring data sharing

disadvantages

  • The loading time of the first screen is slow, the white screen time is long, and the flash screen will appear (actually, the client code has done another client rendering).
  • The server’s VDOM parsing and DOM construction have certain performance loss, and may become a performance bottleneck in the case of heavy traffic
  • The construction process is complex, and problems occur during the construction process
  • Data synchronization is difficult, involving data dehydration and water injection.

Server side rendering

In the traditional sense of server rendering, the server directly generates static pages to return to the client throughout the life cycle of the web page.

advantages

  • The construction process is relatively simple and error location is convenient
  • The server performance cost is low
  • The first screen loads quickly and the white screen time is short

disadvantages

  • The coupling degree of front and back end is high, the code is not flexible enough, and it is difficult to reuse
  • Data manipulation is complex and requires developers to manually intervene in complex DOM operations, which are not suitable for intensive interactions and large data operations
  • Front-end code lack of framework constraints and management, easy to write ancestral code

trade-offs

To sum up, when we are faced with a SEO project, when do we choose front and back isomorphism, and when do we choose traditional server rendering?

In my opinion: If your project is a toC product, requires SEO consideration, involves a lot of user interaction, and frequently changes requirements, then isomorphism is probably better for you. It can build your project as a component, highly decouplable and reusable, and can support some features that are impossible to do in traditional server rendering, such as the web cloud music web client, which can keep songs continuous when switching pages, definitely using isomorphism.

If it is just a toB small and medium-sized enterprise official website, SEO is considered but does not involve a lot of user interaction, and the change is not much once the latter stage is completed, then traditional server rendering can be considered, which is the method mentioned in the following article.

Clear requirements

Before starting the development, we need to make clear the positioning of this project — the official website of the company. Generally speaking, the official website does not involve a lot of data interaction, preferring data display. So without a front-end framework, jquery can do the job. However, for SEO needs to use server-side rendering, it is necessary to use template language (EJS), with Node to complete.

Based on the above information, we can determine the basic functions of the packaging script. Let’s start with a brief list:

  1. Need to bewebpackTo package multi-page applications without adding a view file every time you add oneHTMLWebpackPluginAnd restart server, can achieve webpack configuration and file name decoupling, as much as possible automation.
  2. You need to useejsTemplate language, able to insert variables and externalincludesFile, and finally run the build command to convert the generic template file (<meta>/<title>/<header>/<footer>Etc.) automatically insert the corresponding position of each view file.
  3. Server-side rendering is required, so the development environment is integrated outside of WebPackwebpack-dev-server, you can use your own Node code to start the service.
  4. Have perfectoverlayFunctions can be likewebpack-dev-serverThat integrates pretty overlay screen errors.
  5. Can listen for file changes, automatically package and restart the service, preferably hot update

Begin to build

Start with an empty project, and since we need to write our own server-side code, we need to build more/serverFolder, for storageexpressOur project structure looks something like this when we’re done.

In addition, we need to initialize some generic configuration files, including:

  • .babelrcBabel configuration file
  • .gitignoreGit ignores files
  • .editorConfigEditor configuration file
  • .eslintrc.jsEslint configuration files
  • README.mdfile
  • package.jsonfile

After the big framework came out, we started writing engineering code.

Packaging scripts

Start by writing a package script and creating a few new files in the /build folder

  1. webpack.base.config.jsFor storing webPack configurations common to both production and development environments
  2. webpack.dev.config.jsUsed to store the packaged configuration of the development environment
  3. webpack.prod.config.jsUsed to store packaged configurations for the production environment
  4. config.jsonUsed to store configuration constants such as port names, pathnames, etc.

Generally speaking, the webpack.base.config file contains some common configurations for the development and production environment, such as output and entry, and some loaders, such as babel-loader for compiling ES6 syntax, file-loader for packaging files, etc. The common usage of loaders can be found in the document webpack Loaders.

Note that there is a very important loader ———— ejs-html-loader

Normally, we use html-loader to process view files ending in.html, and then throw it to htMl-webpack-plugin to generate the corresponding file. However, htML-Loader cannot process ejS template syntax <% include… %> syntax, an error is reported. However, in multi-page applications, this include function is necessary, otherwise every view file has to manually write a header/footer. Therefore, we need to configure another EJS-HTml-loader:

// webpack.base.config.js part of the code
module: {
    rules: [{...test: /\.ejs$/,
            use: [
                {
                    loader: 'html-loader'.// Use html-loader to handle image resource references
                    options: {
                        attrs: ['img:src'.'img:data-src']}}, {loader: 'ejs-html-loader'.// Use ejs-html-loader to process the includes syntax of the.ejs file
                    options: {
                        production: process.env.ENV === 'production'}}]}... ] }Copy the code

After the first pit is bypassed, the second:

How do I write the entry?

Remember an old project of the company before, fifty pages, fifty entries and new HTMLwebpackPlugin() a file can be expanded to circle the earth… So to avoid that, I’m going to write a method that returns an entry array.

You can use globs to process these files and get their names, but you can also use native Nodes. Make sure the JavaScript file name is the same as the view file name. For example, if the view file name on the home page is home.ejs, then the corresponding script file name should be home.js. Generate corresponding view files through mapping:

// webpack.base.config.js part of the code
const Webpack = require('Webpack')
const glob = require('glob')
const { resolve } = require('path')

// Webpack entry file
const entry = ((filepathList) = > {
    let entry = {}
    filepathList.forEach(filepath= > {
        const list = filepath.split(/[\/|\/\/|\\|\\\\]/g) // The slash splits the file directory
        const key = list[list.length - 1].replace(/\.js/g.' ') // Get the file filename
        // If it is a development environment, you need to introduce the Hot Module
        entry[key] = process.env.NODE_ENV === 'development' ? [filepath, 'webpack-hot-middleware/client? reload=true'] : filepath
    })
    return entry
})(glob.sync(resolve(__dirname, '.. /src/js/*.js')))

module.exports = {
    entry,
    ...
}
Copy the code

HTMLWebpackPlugin is configured in the same way:

// webpack.base.config.js part of the code.plugins: [
    // Package the file. glob.sync(resolve(__dirname,'.. /src/tpls/*.ejs')).map((filepath, i) = > {
        const tempList = filepath.split(/[\/|\/\/|\\|\\\\]/g)           // The slash splits the file directory
        const filename = `views/${tempList[tempList.length - 1]}`       // Get the file filename
        const template = filepath                                       // Specify the template address as the corresponding EJS view file path
        const fileChunk = filename.split('. ') [0].split(/[\/|\/\/|\\|\\\\]/g).pop() // Get the chunkname of the corresponding view file
        const chunks = ['manifest'.'vendors', fileChunk]               // Assemble the chunks array
        return new HtmlWebpackPlugin({ filename, template, chunks })    // Return the HtmlWebpackPlugin instance})].Copy the code

Compile webpack.base.config.js file, and compile webpack.dev.config.js and webpack.prod.config.js according to your own project requirements. Use webpack-merge to merge the base configuration with the configuration in the corresponding environment.

Other details of Webpack configuration you can refer to the Webpack Chinese website

The service side

With the packaging script written, we started writing the service, which we built using Express. (Because it is a demonstration of engineering architecture, so this service does not involve any database add, delete, change, search, just include basic route jump)

serverThe simple structure is as follows:

Server startup file

The bin/server.js startup file, which acts as an entry point to the service, needs to start both the local service and development time compilation of webpack. Json. When you run NPM run dev, you start the development service using webpack-dev-server. The webpack-dev-server is powerful enough to not only start local services with one click, but also listen for modules and compile in real time. Express + Webpack-dev-Middleware can do the same thing.

Webpack-dev-middleware can be thought of as a detached Webpack-dev-server, but without the ability to start a local service and with a slight change in usage. Its flexibility over Webpack-Dev-Server is that it exists as a middleware, allowing developers to write their own services to use it.

The internal implementation of Webpack-dev-server is also supported by Webpack-dev-Middleware and Express.

The following is part of the code for the service entry file

// server/bin/server.js file code
const path = require('path')
const express = require('express')
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')
const { routerFactory } = require('.. /routes')
const isDev = process.env.NODE_ENV === 'development'
let app = express()
let webpackConfig = require('.. /.. /build/webpack.dev.config')
let compiler = webpack(webpackConfig)

// Enable real-time compilation and hot update only for development environments
if (isDev) {
    // Start webpack compilation with webpack-dev-middleware
    app.use(webpackDevMiddleware(compiler, {
        publicPath: webpackConfig.output.publicPath,
        overlay: true.hot: true
    }))
    
    // Support hot updates with webpack-hot-middleware
    app.use(webpackHotMiddleware(compiler, {
        publicPath: webpackConfig.output.publicPath,
        noInfo: true}}))// Add static resource interception and forwarding
app.use(webpackConfig.output.publicPath, express.static(path.resolve(__dirname, isDev ? '.. /.. /src' : '.. /.. /dist')))

// Construct a route
routerFactory(app)

// Error handling
app.use((err, req, res, next) = > {
    res.status(err.status || 500)
    res.send(err.stack || 'Service Error')
})

app.listen(port, () = > console.log(`development is listening on port 8888`))

Copy the code

Server routing

The jump mode of routing is a very important step in the whole project. If you are reading this article, do you have any questions? Local view files end with the.ejs suffix. Browsers only recognize.html files. Webpack-dev-middleware packages resources that are stored in memory. How can servers access resources stored in memory?

/ / server/routs/home. Js file
const ejs = require('ejs')
const { getTemplate } = require('.. /common/utils')

const homeRoute = function (app) {
    app.get('/'.async (req, res, next) => {
        try {
            const template = await getTemplate('index.ejs') // Get the EJS template file
            let html = ejs.render(template, { title: 'home' })
            res.send(html)
        } catch (e) {
            next(e)
        }
    })
    app.get('/home'.async (req, res, next) => {
        try {
            const template = await getTemplate('index.ejs') // Get the EJS template file
            let html = ejs.render(template, { title: 'home' })
            res.send(html)
        } catch (e) {
            next(e)
        }
    })
}

module.exports = homeRoute

Copy the code

And you can see that the key point is the getTemplate method, so let’s see if this getTemplate is done

/ / server/common/utils. Js file
const axios = require('axios')
const CONFIG = require('.. /.. /build/config')

function getTemplate (filename) {
    return new Promise((resolve, reject) = > {
        axios.get(`http://localhost:8888/public/views/${filename}`) // Note that the 'public' public resource prefix is very important
        .then(res= > {
            resolve(res.data)
        })
        .catch(reject)
    })
}

module.exports = {
    getTemplate
}

Copy the code

As you can see from the code above, a very important thing to do in routing is to request its own service directly with the ejS file name of the corresponding view, thus obtaining resources and data stored in the WebPack cache. After getting the template string in this way, the EJS engine renders the corresponding variables with the data and returns them to the browser as HTML strings for rendering. The local service will mark static resource requests with a publicPath prefix. If the service receives a request with a publicPath prefix, it will be intercepted by the static resource middleware in ‘/bin/server.js’, mapped to the corresponding resource directory, and return static resources. This publicPath is output.publicPath in the WebPack configuration

As for webpack cache, I have searched many places before but failed to find good documents and operation tools. Here I recommend two links for you

  1. Webpack Custom File Systems Webpack Custom File Systems
  2. Memory-fs (get data compiled into memory by Webpack)

The client

After rendering the server and configuring the WebPack build, 80% of the work is done, but there are still some minor details that need to be paid attention to, or the service will still fail when it starts up.

A pit at compile time for Webpack

<%= title %> <%= title %> <%= title %> <%= title %> <%= title %

To solve this problem, you need to first understand how WebPack runs at compile time and what it does. As we know, webpack internal template mechanism is based on EJS, so before we render the server, which is the compilation phase of Webpack, we have executed ejs.render once, at this time, in the webpack configuration file, We did not pass the title variable, so the compilation will report an error. So how do you write it to recognize it? The answer lies inEjs official documentation

As can be seen from the introduction of the official website, when we use the <%% header, it will be escaped into a <% string, similar to the escape of HTML tags, so as to avoid the error recognition of webpack ejS, and generate the correct EJS file. Therefore, take the variable as an example, in the code we need to write: <%%= title %> so that the Webpack can be successfully compiled, and the compiler continues to be passed to the EJS-HTml-loader

Use htML-loader to identify image resources

< span style = “box-sizing: border-box; color: RGB (74, 74, 74); line-height: 22px; font-size: 14px! Important; word-wrap: inherit! Important;” Therefore, we still need to use htMl-loader to process the image reference in HTML. We need to pay attention to the configuration sequence of loader

// webpack.base.config.js part of the code
module: {
    rules: [{...test: /\.ejs$/,
            use: [
                {
                    loader: 'html-loader'.// Use html-loader to handle image resource references
                    options: {
                        attrs: ['img:src'.'img:data-src']}}, {loader: 'ejs-html-loader'.// Use ejs-html-loader to process the includes syntax of the.ejs file
                    options: {
                        production: process.env.ENV === 'production'}}]}... ] }Copy the code

Configuring hot Update

Hot updates are configured in a slightly different way to Webpack-dev-server, but webpack-dev-Middleware is a little simpler. Webpack packages multi-page application configuration hot updates in four steps:

  1. inentryPut one more in the entrancewebpack-hot-middleware/client? reload=trueEntry file of
// webpack.base.config.js part of the code
// Webpack entry file
const entry = ((filepathList) = > {
    let entry = {}
    filepathList.forEach(filepath= >{...// If it is a development environment, you need to introduce the Hot Module
        entry[key] = process.env.NODE_ENV === 'development' ? [filepath, 'webpack-hot-middleware/client? reload=true'] : filepath
        ...
    })
    return entry
})(...)

module.exports = {
    entry,
    ...
}
Copy the code
  1. In webpackpluginsWrite three more plugins in:
    // webpack.dev.config.js file part of the code
    plugins: [...// OccurrenceOrderPlugin is needed for webpack 1.x only
    new Webpack.optimize.OccurrenceOrderPlugin(),
    new Webpack.HotModuleReplacementPlugin(),
    // Use NoErrorsPlugin for webpack 1.x
    new Webpack.NoEmitOnErrorsPlugin()
    
    ...
    ]
    Copy the code
  2. inbin/server.jsIn the service entrywebpack-hot-middlewareAnd willwebpack-dev-serverPackaged upcompilerwebpack-hot-middlewareWrap up:
    / / server/bin/server. The js file
    let compiler = webpack(webpackConfig)
    
    // Start webpack compilation with webpack-dev-middleware
    app.use(webpackDevMiddleware(compiler, {
        publicPath: webpackConfig.output.publicPath,
        overlay: true.hot: true
    }))
    
    // Support hot updates with webpack-hot-middleware
    app.use(webpackHotMiddleware(compiler, {
        publicPath: webpackConfig.output.publicPath,
        reload: true.noInfo: true
    }))
    Copy the code
  3. Add a line of code to the view’s js file:
    / / SRC/js/index. Js file
    if (module.hot) {
        module.hot.accept()
    }
    Copy the code

For more configuration details on Webpack-hot-Middleware, see the documentation

The webpack Hot Module can only support JS changes if it needs to support style files (CSS/less/sass…). On hot reload, you can’t use extract-text-webpack-plugin to peel out the style file, otherwise you can’t listen for changes and refresh in real time. 2. Webpack Hot Module does not support HTML hot replacement, but many developers have a large demand for this, so I found a relatively simple method to support hot update of view files

/ / SRC/js/index. Js file
import axios from 'axios'
// styles
import 'less/index.less'

const isDev = process.env.NODE_ENV === 'development'

// In a development environment, import ejS template files using raw-loader to force WebPack to treat them as part of the bundle that needs to be hot updated
if (isDev) {
    require('raw-loader! . /tpls/index.ejs')}...if (module.hot) {
    module.hot.accept()
    /** * listen for hot Module completion event, get template from server again, replace document * If you bind events to elements before, they may be invalidated after hot updates * 2. If the event is not destroyed before the module is unloaded, it may cause a memory leak
    module.hot.dispose(() = > {
        const href = window.location.href
        axios.get(href).then(res= > {
            const template = res.data
            document.body.innerHTML = template
        }).catch(e= > {
            console.error(e)
        })
    })
}

Copy the code
// webpack.dev.config.js
plugins: [...new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('development')})... ]Copy the code
// webpack.prod.config.js
plugins: [...new webpack.DefinePlugin({
        'process.env.NODE_ENV': JSON.stringify('production')})... ]Copy the code

OK, as you expected, view files now support hot updates as well. 😃 😃

Webpack-hot-middleware inherits overlays by default, so overlay error reporting will work when hot updates are configured

Package. json starts the script

Finally, let’s take a look at the startup script in package.json

    "scripts": {
        "clear": "rimraf dist"."server": "cross-env NODE_ENV=production node ./server/bin/server.js"."dev": "cross-env NODE_ENV=development nodemon --watch server ./server/bin/server.js"."build": "npm run clear && cross-env NODE_ENV=production webpack --env production --config ./build/webpack.prod.config.js"."test": "echo \"Error: no test specified\" && exit 1"
    }
Copy the code

When the client code changes, Webpack will automatically help us compile and restart, but the server code changes will not be refreshed in real time. At this time, Nodemon is needed. After setting the listening directory, any code changes on the server can be monitored by Nodemon and the service will automatically restart, which is very convenient.

There is also a small detail to note here. Nodemon — Watch had better specify the server side folder to listen to, because after all, only the server side code changes need to restart the service, otherwise the default listening to the entire root directory, write a style can restart the service, it is boring.

conclusion

Looking back after the whole project is completed, there are still a lot to pay attention to and worth learning. I stepped in a lot of holes, but I also got a better understanding of some of the principles.

Thanks to the front-end scaffolding tool, we can generate the basic configuration of the project in most projects with one click, which saves a lot of trouble of engineering construction. But this convenience benefits the developers at the same time, but also weakens the engineering architecture ability of the front-end engineers. There are always business scenarios that scaffolding tools can’t reach, and developers need to proactively seek solutions, or even build their own projects, to maximize the flexibility of development.

The complete project address can be viewed on my GitHub, if you like to give a Star⭐️, thank you ~😃😃