从0开始构建一个前端工程架构

2017年4月1日远眺天津之眼

零、前端技术选型

1、多页应用

① 特点:

  • 内容都是由服务端用模板生成,如JSP,Django等
  • 每次页面跳转都要经过服务端,会给用户一定等待时间
  • JavaScript更多只是做页面的事件、动画等

② 主要的类库:

  • jQuery(快速操作DOM,兼容各类浏览器)
  • YUI

③ 架构工具:

  • 没有特定的前端工具,跟后端配合
  • grunt / gulp

④ 模块化工具:
seajs
requirejs

⑤ 静态文件
使用gulp或grunt等工作手动编译到html中,自由度低,操作复杂,或者不处理,直接交给后端,让后端处理。

2、单页应用

① 特点:

  • 所有内容都在前端生成
  • JavaScript承担更多的业务逻辑,后端只提供API
  • 页面路由跳转不需要经过后端,由前端生成路由

② 常用类库:

  • Backbone.js
  • Angular.js(typescript, .ts,http2)
  • React.js(.jsx,单向数据流,方便数据管理)
  • Vue.js(.vue, template,数据双向绑定)

③ 架构工具

  • npm包管理器
  • bower
  • jspm(前端类库单独出来,使用http2,效率更高)

④ 模块化工具

  • webpack
  • rollup(按需加载,打包更快)
  • browserify

⑤ 静态文件
可以直接在JavaScript代码中进行引用,并且交由模块化工具转化成线上可用的静态资源,可以定制转化过程适应不同的需求场景。

⑥ 其他考虑因素

  • 浏览器兼容性
  • 移动端还是PC端
  • 面向市场用户(重交互,性能),还是面向企业使用(重安全,功能)

一、为什么要使用工程架构?

① 极大地解放了生产力,提高了开发效率

  • 源代码预处理,将项目框架语言最终转化为浏览器能看懂的JavaScript
  • 自动打包,自动更新页面显示
  • 自动图片处理依赖,保证开发和正式环境的统一

② 围绕解决方案搭建环境

  • 不同的前端框架需要不同的运行架构
  • 语气可能出现的问题并规避

③ 保证项目质量

  • 代码规范,使用Eslint配置
  • 不同操作系统或编辑器下差异的排除,使用editorconfig配置
  • git commit预处理,提交代码前,强制执行代码规范

二、webpack基本配置

1、什么是webpack?

Webpack is a module bundler for modern JavaScript applications.
webpack官方介绍,它是一个现代JavaScript应用诞生的模块打包器。

2、基础配置
// webpack.config.client.js
const path = require('path');

module.exports = {
   entry: {      // (1)
       app: path.join(__dirname, '../client/app.js'),
   },
   output: {    // (2)
       filename: '[name].[hash:5].js',    // (3)
       path: path.join(__dirname, '../dist'),    // (4)
       publicPath: '/public'          // (5)
   }
}
  • (1) enrty是指定了一个资源文件的路径
  • (2) output指定了输出文件的目录
  • (3) filename是输出文件名,可以设置带有hash值的名称,方便文件修改时文件名改变,避免浏览器缓存带来的不必要麻烦。
  • (4) path用来存放打包后文件的输出目录
  • (5) publicPath用来配置发布到线上的URL前缀,默认值为' ',即使用相对路径。
    当需要构建出的资源文件上传到CDN服务商时候,为了加快页面的打开速度,需要如下配置:
filename: '[name]_[chunkhash:5].js',
publicPath: 'https://cdn.example.com/assets/'

此时发布到线上的HTML在引入JavaScript文件时,就需要如下配置项:
<script src="https://cdn.example.com/assets/a_12345.js"></script>

3、loader基础应用
// webpack.config.client.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: {
        app: path.join(__dirname, '../client/app.js'),
    },
    output: {
        filename: '[name].[hash:5].js',
        path: path.join(__dirname, '../dist'),
        publicPath: '',
    },
    module: {
        rules: [
            {
                test: /.jsx$/,
                loader: 'babel-loader',     // (1)
            },
            {
                test: /.js$/,
                loader: 'babel-loader', 
                exclude: [          // (2)
                    path.join(__dirname, '../node_modules')
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin()     // (3)
    ]
}
  • (1) 当页面需要引入一些非原生JavaScript的模块(module)时,需要在module中设置各种loader进行操作,方便浏览器识别,如对react的.jsx文件进行支持,需要引入babel-loader
  • (2) 不需要对node_modules中的js进行编译,则需要用exclude进行排除,同时需要设置在项目根目录设置.babelrc,如下,对es6和react进行编译和宽松的支持。
// .babelrc
{
    "presets": [
        ["es2015", {"loose": true}],
        "react"
    ]
}
  • (3) webpack 通过plugins安装插件类实现一些功能,常用的如,HtmlWebpackPlugin,它依据一个简单的模板,生成最终的html文件,这个文件中自动引用了打包后的JS文件。每次编译都在文件名中插入一个不同的带有哈希值的JS文件。

三、服务端渲染基础配置

1、为什么需要服务端渲染(SSR:Server-Side-Render)
  • (1) SEO不友好,单页应用在浏览器端渲染HTML页面,搜索引擎不会去执行JS代码,不便于搜索引擎的录入
  • (2) 用户体验不好,页面渲染每次要等待JS加载完成之后才出现,首次请求等待时间较长,会出现一个短暂的白屏。
2、React中怎么使用服务端渲染

react-dom 是React专门为web端开发的渲染工具,可以在客户端用react-dom的render方法渲染组件,而服务端,react-dom/server 提供我们将react组件渲染成HTML的方法。

// 入口文件app.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.jsx';

ReactDOM.hydrate(<App />, document.getElementById('root'));  // (1)
  • (1) hydrate 描述的是 ReactDOM 复用 ReactDOMServer 服务端渲染的内容时尽可能保留结构,并补充事件绑定等 Client 特有内容的过程。

服务端渲染时使用Nodejs,Nodejs是没有document或者window对象的,需要创建一个特定的js文件(server-entry.js)去导出要在服务端渲染的内容,如下:

// server-entry.js
import React from 'react';
import App from 'App.jsx';

export  default <App />;

因为这个server-entry.js需要在Nodejs中执行,所以导出的jsx格式是需要webpack打包编译的,所以需要再建一个webpack配置文件,如下:

// webpack.config.server.js
const path = require('path');

module.exports = {
    target: 'node',  //  (1)
    entry: {
        app: path.join(__dirname, '../client/server-entry.js'),
    },
    output: {
        filename: 'server-entry.js',                 // (2) 
        path: path.join(__dirname, '../dist'),
        publicPath: '/public',
        libraryTarget: 'commonjs2',    // (3)
    },
    module: {
        rules: [
            {
                test: /.jsx$/,
                loader: 'babel-loader',
            },
            {
                test: /.js$/,
                loader: 'babel-loader', 
                exclude: [
                    path.join(__dirname, '../node_modules')
                ]
            }
        ]
    },
          //  (4)
}

与webpack.config.client.js基本相同,个别地方做了一些特定的改动:

  • (1) target: 打包出来的内容执行环境设置,这里因为需要打包后放到服务端Nodejs中执行,所以选择node。还可以是web,就是在浏览器端执行。

  • (2) 因为服务端不会有缓存一说,所以不需要对压缩后的文件名进行hash处理,所以打包后的文件名不变。

  • (3) libraryTarget:设置打包出来的js模块方案,此处使用commonjs2规范,即最新的commonjs最新的模块加载方案,适用于Nodejs端。还可以设置amdcmd等。

  • (4) 同时删除了HtmlWebpackPlugin,因为不需要服务端渲染时,不需要再生成一个Html文件了。

此时,需要对package.json中的scripts做一些修改:

  "scripts": {
    "build:client": "webpack --config build/webpack.config.client.js",
    "build:server": "webpack --config build/webpack.config.server.js",
    "clear": "rimraf dist",                                            // (1)
    "build": "npm run clear && npm run build:client && npm run build:server",// (2)
    "start": "node server/server.js"
  },
  • (1) rimraf是nodejs一个专门用来删除文件夹的包,这里是在每次打包压缩新的dist文件之前把之前的dist文件删除

  • (2) build命令把build:client和build:server两个命令都去执行一遍

在client文件夹中新增一个t模板emplate.html文件,同时在webpack.config.client,js中添加如下配置,将模板template.html合到导出后的index.html中。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <div id="root"><app></app></div>
</body>
</html>
plugins: [
        new HtmlWebpackPlugin({
            template: path.join(__dirname, '../client/template.html')
        })
    ]

当打包完成后,在服务端就可以开始渲染server-entry.js了:
使用Node.js的Express框架,来做服务端的渲染工作:

const express = require('express');
const ReactSSR = require('react-dom/server');
const serverEntry = require('../dist/server-entry.js').default;  (1)
const fs = require('fs');
const path = require('path');

const app = express();
const template = fs.readFileSync(path.join(__dirname, '../dist/index.html'), 'utf8');

app.use('/public', express.static(path.join(__dirname, '../dist')));  // (2)

app.get('*', function(req, res) {
    const appString = ReactSSR.renderToString(serverEntry); // (3)
    res.send(template.replace('<app></app>', appString));  // (4)
});

app.listen(3333, function(){
    console.log('server is running! Visit http://localhost:3333');
})
  • (1) const serverEntry = require('../dist/server-entry.js').default;这里结尾之所以要加上.default,是因为用require引入的模块是整个内容,和import不同,当打印没有加.default的serverEntry时,可以发现如下:
{ __esModule: true,
  default:
   { '$$typeof': Symbol(react.element),
     type: [Function: App],
     key: null,
     ref: null,
     props: {},
     _owner: null,
     _store: {} } }

default里才是需要的内容,所以需要找到default里。

  • (4) 用来区分静态内容还是服务端代码。

  • (3) 在浏览器端最终产生的是DOM元素,而在服务器端,最终产生的是字符串,因为返回给浏览器的是HTML字符串,所以服务器渲染不需要指定容器元素,只有一个返回字符串函数renderToString

  • (4) 为了和浏览器端一致,把返回的字符串嵌入到id="root"的与元素里,即用appString代替index.js中id="root"里的'<app></app>'。
    服务端渲染后的页面代码,如下图:


    服务端渲染

四、webpack-dev-server

Use webpack with a development server that provides live reloading. This should be used for development only.

根据官方的说明,可以知道,webpack-dev-server是一个用在开发环境中的一个服务,可以实时更新。

1、安装

安装webpack-dev-server和cross-env包
npm install webpack-dev-server --save-dev
npm i cross-env --save-dev

2、使用

首先,在package.json中的scripts中增加一个"dev:client",如下:
"dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.config.client.js",
因为webpack-dev-server用于开发环境,所以在webpack配置文件中需要加以区分是开发环境还是生产环境,在webpack.config.client,js添加如下配置:
将之前的module.exports={}的写法改成const config={};然后给一个if条件判断if(diDev) {};最后再module.exports = config;

const  isDev = process.env.NPDE_ENV === 'development'; // (1)

const config = {
    entry: {
        app: path.join(__dirname, '../client/app.js'),
    },
    output: {
        filename: '[name].[hash:5].js',
        path: path.join(__dirname, '../dist'),
        publicPath: '/public',
    },
    module: {
        rules: [
            {
                test: /.jsx$/,
                loader: 'babel-loader',
            },
            {
                test: /.js$/,
                loader: 'babel-loader', 
                exclude: [
                    path.join(__dirname, '../node_modules')
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: path.join(__dirname, '../client/template.html')
        })
    ]
}

// 只有在开发中才使用的
if (isDev) {
    config.entry = {
        app:[
            'react-hot-loader/patch',
            path.join(__dirname, '../client/app.js')
        ]
    }
    config.devServer = {  // (2)
        host: '0.0.0.0',  // (3)
        port: '8088',
        contentBase: path.join(__dirname, '../dist'),// (4)
        hot: true,  // (5)
        overlay: {   // (6)
            errors: true
        },
        publicPath: '/public/', // (7)
        historyApiFallback: {  // (8)
            index: '/public/index.html'
        },
    }
}

module.exports = config;
  • (1) 定义一个开发环境给变量isDev
  • (2) 设置一个devServer
  • (3) 如果想外部其他人也能访问,host就设为0.0.0.0,默认为localhost
  • (4) 告诉服务器从哪里提供内容。只有在你想要提供静态文件时才需要。因为静态文件是放在output中的path中配置,所以这里和output中的path路径一致就行。
  • (5) 启用 webpack 的模块热替换(hot-module-replacement)特性
  • (6) 如果webpack在编译过程中出错,则在网页显示信息
  • (7) 此路径下的打包文件可在浏览器中访问,确保 publicPath 总是以斜杠(/)开头和结尾。
    假设服务器运行在 http://localhost:8080 并且 output.filename 被设置为 bundle.js。默认 publicPath 是 "/public/",所以你的包(bundle)可以通过 http://localhost:8080/public/bundle.js 访问。
  • (8) 任意的 404 响应被替代为设置的index

注意:当启动webpack-dev-server时,最好是删除之前已经打包的文件,如dist文件夹,因为当启动devServer时候,默认在硬盘中检测打包项目目录,如果有,则直接访问这个dist文件下js文件的,但这个dist下的js文件和html引入的js文件名不一致,所以会造成404错误。

五、hot-module-replacement

可以在开发环境中,修改代码后,在页面中无刷新的看到代码变化后的效果,极大的提高了开发效率。

1、安装

安装react-hot-loader插件,它提供了React的hot-module-replacement的功能的工具。
npm install react-hot-loader --save-dev

2、配置

配置.babelrc文件:

"plugins": [
        "react-hot-loader/babel"
    ]

修改webpack.config.client.js文件,添加HotModuleReplacementPlugin:

const webpack = require('webpack');  // 引入webpack
if (isDev) {
    config.entry = {
        app:[
            'react-hot-loader/patch',
            path.join(__dirname, '../client/app.js')
        ]
    }
// 在开发环境中加入一个HotModuleReplacementPlugin
   config.plugins.push(new webpack.HotModuleReplacementPlugin())
}

修改app.js:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.jsx';
import { AppContainer } from 'react-hot-loader';

const render = Component => {
    ReactDOM.hydrate(
        <AppContainer>
            <Component />
        </AppContainer>,
        document.getElementById('root')
    )
}

render(App);

if (module.hot) {
    module.hot.accept('./App.jsx', () => {  // (1)
        const NextApp = require('./App.jsx').default;
        render(NextApp)
    })
}
  • (1) accept方法给定依赖模块的更新,并触发一个 回调函数 来对这些更新做出响应。

六、规范代码

常用的规范代码主要从代码规范和代码书写格式等方面进行规范,如eslint规范代码风格,editorconfig规范代码文本格式等。

1、为什么要规范代码
  • (1) 规范代码有利于团队协作
  • (2) 纯手工规范费时费力,而且不能保证准确性
  • (3) 能配合编辑器(如vscode)自动提醒错误,提高开发效率
2、Eslint

安装:
npm install eslint --save-dev
npm install babel-eslint --save-dev
npm install eslint-config --save-dev
npm install eslint-config-airbnb --save-dev
npm install eslint-config-standard --save-dev
npm install eslint-loader --save-dev
npm install eslint-plugin-import --save-dev
npm install eslint-plugin-jsx-a11y --save-dev
npm install eslint-plugin-node --save-dev
npm install eslint-plugin-promise --save-dev
npm install eslint-plugin-react --save-dev
npm install eslint-plugin-standard --save-dev
配置:
在项目根目录创建一个.eslintrc文件,然后添加配置项:

{
    "extends": "standard"
}

直接继承标准的eslint标准就行

另外,在client文件目录下再建一个.eslintrc文件,因为client文件中主要代码是用jsx语法来书写的,很多写法和nodejs端是不一样的,需要单独配置,如下:

{
    "parser": "babel-eslint",
    "env": {
        "browser": true,
        "es6": true,
        "node": true
    },
    "parserOption": {
        "ecmaVersion": 6,
        "sourceType": "module"       // (1)
    },
    "extends":"airbnb",
    "rules": {
        "semi": [0]
    }
}

主要继承了美国Airbnb公司的React eslint规则。

  • (1) 定义文件是 ECMAScript 模块

如果希望代码在每次编译之前使用这些eslint规则去检查一遍,可以在webpack.client.config.js中设置:
在module中增加一条rules:

{
    enforce: 'pre',  // (1)
    test: /.(js|jsx)$/,
    loader: 'exlint-loader',
    exclude: [
                    path.join(__dirname, '../node_modules')
     ]
}
  • (1) 在代码编译之前,执行eslint-loader,如果出现错误,停止编译,报出错误。
3、editorconfig

在根目录创建.editorconfig文件
注意:如果是在vscode编辑器中使用.editorconfig,需要安装一个插件 EditorConfig for VS Code,才能生效。

root = true

[*]

charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
insert_final_newline = true

完整代码:请移步github

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容