聊一聊前端「同构」

1、什么是同构

同构是指同开发一个能够跑在不一样的平台上的程序。例如开发一段 js 代码能够同时被基于 node.js 开发的 web server 和浏览器使用。本文中咱们就要聊聊这种场景下,为何以及怎么样开发一个同构的 web 应用。html

2、同构带来的好处

咱们不会无缘无故地作出任何决策,你们使用同构确定是由于同构可以带来一些好处:前端

  • 减小代码开发量, 提升代码复用量。由于一份代码能同时跑在浏览器和服务器,所以不只代码量减小了,并且不少业务逻辑不须要在浏览器和服务端两边同时维护,于是同时减少了程序出错的可能。
  • 能够以较小的成本完成 SSR (Server-Side Render)的功能。而 SSR 能带来至少如下两点好处。
    • 首屏性能,让用户更早看到页面内容。
    • SEO (Search Engine Optimization), 对爬虫友好。

3、同构带来的问题

  • 性能损失,客户端服务端都要渲染页面, 存在必定的性能浪费(能够经过客户端 dom 反收集和 virtual-dom 等手段尽可能优化,但不可避免)。
  • 一个能够同构的模块必须同时兼容客户端和 Node.js 环境,所以会带来额外的一些开发成本。特别是习惯客户端开发的人要注意 window,document,DOM 等是客户端才存在的对象。
  • 服务端内存溢出的风险,客户端代码运行环境随着浏览器刷新会从新创建,所以不须要太注意内存溢出的问题,而服务端则不一样。
  • 要特别注意异步操做,习惯于客户端开发的同窗可能很习惯在前端随意发起异步数据请求和操做,由于全部的操做都会引发页面重绘。而服务端则不一样,服务端的组件只能调用一次或有限次的 render,因此全部用于服务端渲染的异步请求必须所有都调用 render 返回 html 前完成。
  • 全部在服务端预取的状态都应该有途径能让客户端获取,以避免客户端和服务端渲染结果不一样致使闪屏。由于不管如何客户端都会渲染一次页面,若服务端用来渲染的数据和客户端不同,那么渲染出来的 dom 也会不同,致使闪屏。

4、应用的哪些部分能够同构

  1. 单页应用的路由能够被同构,这样访问任意单页应用的子页面均可以享受 SSR 带来的好处。
  2. 模板,先后端共用一个渲染引擎就能够作到先后端共用模板,这样相似于因同一份数据要用于先后端渲染而须要开发两套模板的日子就一去不复返了。
  3. 数据请求,开发支持同构的 httpClient,那么先后端请求数据的代码也能够同构了。须要注意的是服务端没有 cookie,所以会话相关的请求代码须要极其当心。
  4. 其余平台不相关的代码,例如 react 和 vue 都有的全局状态管理模块、数据处理过程和一些平台无关的纯函数。

5、哪些东西不能同构

  • 平台相关代码,如只能在浏览器端执行 DOM、BOM 相关的操做,只能在服务端执行文件读写,数据库操做等。

6、咱们到底需不须要同构

6.1 同构带来的好处能够经过别的途径来获取吗?

  • SSRvue

    SSR 固然不是必须经过同构来实现的,但使用同构来实现 SSR 能够减小大量重复的代码开发。node

  • 减小由于先后端使用两份代码同时维护一份逻辑而出错的可能性react

    我没有想到比同构能更好地解决这个问题的方案了。webpack

在 SSR 是必需的时候,我感受同构仍是有必要的。ios

6.2 支持同构,但不滥用 SSR

所以我以为一个比较良好的方案是开发一个支持同构的应用,但不强制使用 SSR,由于 SSR 带来必定的性能浪费。git

  • 支持同构,一份代码便可以跑在客户端又能够跑在服务端,但具体要不要在服务端跑这段代码由具体业务来决定。
  • 仅在首屏性能需求高和有 SEO 需求时使用 SSR,其余状况使用单纯的客户端渲染,这看起来是一个比较好的折中方案。

7、从零开始写一个支持同构的多页应用

例子代码仓库地址
启动例子的同时阅读下面的段落体验会更好。例子中使用了 express 做为 web server 框架,所以读者如有一些 express 基础会更容易理解例子。github

7.1 先后端代码的职责

  • 前端:[(单页应用)处理路由-> ] 请求数据 -> 渲染 -> 绑定事件
  • 后端:[ 处理路由 -> ] 请求数据 -> 渲染

7.2 先后端代码做用的差别

  • 前端:没有输出,代码直接做用于页面元素
  • 后端:输出 html 字符串

7.3 判断代码执行环境

最简单的方式就是经过 window 对象的存在与否来判断当前的代码执行环境,只有在浏览器执行环境 window 对象才存在web

const isBrowser = typeof window !== 'undefined';
复制代码

7.4 同构应用基本设计

基本设计

7.5 同构的组件基类

7.5.1 生命周期规划

一个同构的组件,它的生命周期在服务端和客户端的执行状况是不一样的。在 mount 操做前的生命周期能够跑在服务端。

  • 客户端:
    beforeMount -> render -> mounted
  • 服务端:
    preFetch -> beforeMount -> render

beforeMount 和 render 生命周期 在服务端和客户端都会被执行,所以这两个生命周期内也不该该写平台相关的代码。

下面是此次 demo 使用的同构组件基类:

// ./lib/Component.js
const {isBrowser} = require('../utils');

module.exports = class Component {
    constructor (props = {}, options) {
        this.props = props;
        this.options = options;
        this.beforeMount();
        if (isBrowser) {
            // 浏览器端才执行的生命周期
            this.options.mount.innerHTML = this.render();
            this.mounted();
            this.bind();
        }
    }
    // 生命周期
    async preFetch() {}
    // 生命周期
    beforeMount() {}
    // 生命周期
    mounted() {}
    // 绑定事件时使用
    bind() {}
    // 从新渲染时调用
    setState() {
        this.options.mount.innerHTML = this.render();
        this.bind();
    }
    render() {
        return '';
    }
};
复制代码

全部的业务组件都继承这个基类,例如一个实际业务组件以下:

// ./pages/index.js
const Component = require('../lib/Component');

module.exports = class Index extends Component {
    render() {
        return `
            <h1>我是首页</h1>
            <a href="/list">列表页</a>
        `;
    }
}
复制代码

启动例子后能够访问 http://localhost:3000/ 来访问这个页面,能够观察一下 SSR 的状况。

7.6 服务端处理

7.6.1 使用 ServerRenderer 来渲染组件

一个简单的 ServerRenderer 实现以下:

// ./lib/ServerRenderer.js
const path = require('path');
const fs = require('fs');

module.exports = async (mod) => {
    // 获取组件
    const Component = require(path.resolve(__dirname, '../', mod));
    // 获取页面模板
    const template = fs.readFileSync(path.resolve(__dirname, '../index.html'), 'utf8');
    // 初始化业务组件
    const com = new Component()
    // 数据预取
    await com.preFetch();
    // 将组件渲染的字符串输出到页面模板
    return template.replace(
        '<!-- ssr -->', 
        com.render() +
            // 把后端获取的数据放到全局变量中供前端代码初始化
            '<script>window.__initial_props__ = ' + 
            JSON.stringify(com.props) +
            '</script>'
    )
    // 替换插入静态资源标签
    .replace('${modName}', mod);
}
复制代码
7.6.2 页面模板

此次 demo 的全部页面都使用同一份 html 模板:

<!-- ./index.html -->
<html>
    <head>
        <title>test</title>
    </head>
    <body>
        <div id="app">
            <!-- 下面是 ssr 渲染后内容填充的占位符 -->
            <!-- ssr -->
        </div>
        <!-- 插入实际业务的前端 js 代码 -->
        <script src="http://localhost:9000/build/${modName}"></script>
    </body>
</html>
复制代码
7.6.3 服务端路由和 controller

这次的 demo 是基于 express 框架开发的,下面的代码使用 ServerRenderer 渲染同构的组件,而后输出 页面 html 给浏览器。

// ./routes/index.js
var express = require('express');
var router = express.Router();
var ServerRenderer = require('../lib/ServerRenderer');

router.get('/', function(req, res, next) {
  ServerRenderer('pages/index.js').then((html) => {
    res.send(html); 
  });
}); 
复制代码

7.7 客户端处理器 ClientLoader

demo 中的 ClientLoader 是一个 webpack loader,该 loader 代码以下:

// ./lib/ClientLoader.js
module.exports = function(source) {
    return `
        ${source}
        // 入口文件 export 的是主组件
        const Com = module.exports;
        // 获取后端渲染时使用的初始状态 window.__initial_props__,保证先后端渲染结果一致。
        new Com(window.__initial_props__, {
            mount: document.querySelector('#app')
        });
    `;
};
复制代码

在 webpack.config.js 中使用这个插件(仅做用于页面入口组件)

// webpack.config.js
module.exports = {
    ...,
    module: {
        rules: [
        ...,
            {
                test: /pages\/.+\.js$/,
                use: [
                    {loader: path.resolve(__dirname, './lib/ClientLoader.js')}
                ]
            }
        ],
    }
};
复制代码

7.8 一个业务组件

有了以上的基础之后,咱们能够轻易的写一个支持同构的组件。下面是一个列表页。

7.8.1 代码
// ./routes/index.js
/* GET list page. */
router.get('/list', function(req, res, next) {
  ServerRenderer('pages/list.js').then((html) => {
    res.send(html); 
  });
});

// ./pages/list.js
const Component = require('../lib/Component');
const {
    getList,
    addToList
} = require('../api/list.api');

module.exports = class Index extends Component {
    constructor (props, options) {
        super(props, options);
    }
    // 服务端执行,预取列表数据
    async preFetch() {
        await this.getList();
    }
    async getList() {
        const list = (await getList()).data;
        this.props.list = list;
    }
    ...,
    render() {
        return `
            <h1>我是列表页</h1>
            <button class="add-btn">add</button>
            <button class="save-btn">save</button>
            <ul>
                ${
                    this.props.list.length ? 
                    this.props.list.map((val, index) => `
                        <li>
                            ${val.name}
                            <button class="del-btn">删除</button>
                        </li>
                    `).join('') :
                    '列表为空'
                }
            </ul>
        `;
    }
}
复制代码
7.8.2 服务端渲染结果

若已启动 demo 服务器,访问 http://localhost:3000/list 能够看到服务端的渲染结果以下。window.__initial_props__ 的存在保证先后端渲染的结果一致。

<html>
    <head>
        <title>test</title>
    </head>
    <body>
        <div id="app">
            <h1>我是列表页</h1>
            <button class="add-btn">add</button>
            <button class="save-btn">save</button>
            <ul>
                <!-- 服务端渲染预取的列表数据 -->
                <li>
                    1
                    <button class="del-btn">删除</button>
                </li>
                <li>
                    2
                    <button class="del-btn">删除</button>
                </li>
                <li>
                    3
                    <button class="del-btn">删除</button>
                </li>
                <li>
                    4
                    <button class="del-btn">删除</button>
                </li>
            </ul>
            <script>
                // 预取的列表数据, 用来客户端渲染
                // 客户端第一次渲染和服务端渲染结果相同,所以用户看不到客户端渲染的效果。
                window.__initial_props__ = {
                    "list": [{
                        "name": 1
                    }, {
                        "name": 2
                    }, {
                        "name": 3
                    }, {
                        "name": 4
                    }]
                }
            </script>
        </div>
        <script src="http://localhost:9000/build/pages/list.js"></script>
    </body>
</html>
复制代码

8、一个单页面同构应用。

比起多页面应用,单页面应用须要多同构前端路由的部分。

8.1 服务端处理

初始化组件时带上路由信息:

// ./lib/ServerRenderer.js
module.exports = async (mod, url) => {
    ...
    // 初始化业务组件
    const com = new Component({
        url
    });
    ...
}
复制代码

书写 controller 时把路由输入 ServerRenderer:

/* GET single page. */
router.get('/single/:type', function(req, res, next) {
  ServerRender('pages/single.js', req.url).then((html) => {
    res.send(html); 
  });
});
复制代码

8.2 客户端代码

下面是一个单页应用组件,点击切换按钮就能够纯前端的切换路由并改变视图:

// ./pages/single.js
const Component = require('../lib/Component');

module.exports = class Index extends Component {
    switchUrl() {
        const isYou = this.props.url === '/single/you';
        const newUrl = `/single/${isYou ? 'me' : 'you'}`;
        this.props.url = newUrl;
        window.history.pushState({}, 'hahha', newUrl);
        this.setState();
    }
    bind() {
        this.options.mount.getElementsByClassName('switch-btn')[0].onclick = this.switchUrl.bind(this);
    }
    render() {
        ;
        return `
            <h1>${this.props.url}</h1>
            <button class="switch-btn">切换</button>
        `;
    }
}
复制代码

访问 /single/you 服务端返回的内容为:

<html>
    <head>
        <title>test</title>
    </head>
    <body>
        <div id="app">
            <h1>/single/you</h1>
            <button class="switch-btn">切换</button>
            <script>
                window.__initial_props__ = {
                    "url": "/single/you"
                }
            </script>
        </div>
        <script src="http://localhost:9000/build/pages/single.js"></script>
    </body>
</html>
复制代码

9、公共状态管理的同构

公共状态管理的同构和组件的 props 同构其实很是相似,都须要把后端预取数据之后的整棵状态树渲染到页面上而后前端初始化状态管理器 store 的时候使用这棵树来作为初始状态,以此来保证前端渲染的结果和后端一致。

10、特殊的 HttpClient

上面使用的 demo 中使用的 httpClient 是 axios,这个库自己就已经支持同构。但仍是有一个问题,这个问题以前也提到过。
当涉及到会话相关的请求时,通常状况下浏览器发送请求时会带上 cookie 信息,可是服务端发起的请求并不会。所以,服务端发起请求时,须要手动地把 cookie 加到请求头中去。

知识共享许可协议
本做品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。