• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    迪恩网络公众号

mcuking/mobile-web-best-practice: 移动 web 最佳实践

原作者: [db:作者] 来自: 网络 收藏 邀请

开源软件名称(OpenSource Name):

mcuking/mobile-web-best-practice

开源软件地址(OpenSource Url):

https://github.com/mcuking/mobile-web-best-practice

开源编程语言(OpenSource Language):

TypeScript 61.6%

开源软件介绍(OpenSource Introduction):

mobile-web-best-practice

本项目以基于 vue-cli3typescript 搭建的 Todo 应用为例,阐述了在使用 web 进行移动端开发中的一些最佳实践方案(并不局限于 Vue 框架)。另外其中很多方案同样适用于 PC 端 Web 开发。

笔者会不定期地将实践中的最佳方案更新到本项目中。

在线体验

该 Todo 应用交互简洁实用,另外无需服务器,而是将数据保存到 webview 的 indexDB 中,可保证数据安全,欢迎在实际工作生活中使用。效果图如下:

体验平台 二维码 链接 备注
Web 点击体验
Android 点击体验 安装密码:123456

目录

分层架构

react-clean-architecture

business-rules-package

ddd-fe-demo

目前前端开发主要是以单页应用为主,当应用的业务逻辑足够复杂的时候,总会遇到类似下面的问题:

  • 业务逻辑过于集中在视图层,导致多平台无法共用本应该与平台无关的业务逻辑,例如一个产品需要维护 Mobile 和 PC 两端,或者同一个产品有 Web 和 React Native 两端;

  • 产品需要多人协作时,每个人的代码风格和对业务的理解不同,导致业务逻辑分布杂乱无章;

  • 对产品的理解停留在页面驱动层面,导致实现的技术模型与实际业务模型出入较大,当业务需求变动时,技术模型很容易被摧毁;

  • 过于依赖前端框架,导致如果重构进行框架切换时,需要重写所有业务逻辑并进行回归测试。

针对上面所遇到的问题,笔者学习了一些关于 DDD(领域驱动设计)、Clean Architecture 等知识,并收集了类似思想在前端方面的实践资料,形成了下面这种前端分层架构:

其中 View 层想必大家都很了解,就不在这里介绍了,重点介绍下下面三个层的含义:

Services 层

Services 层是用来对底层技术进行操作的,例如封装 AJAX 请求,操作浏览器 cookie、locaStorage、indexDB,操作 native 提供的能力(如调用摄像头等),以及建立 Websocket 与后端进行交互等。

其中 Services 层又可细分出 request 层和 translator 层, request 层主要是实现 Services 的大部分功能。而 translator 层主要用于清洗从服务端或客户端接口返回的数据:删除部分数据、修改属性名、转化部分数据等,一般可定义成纯函数形式。下面以本项目实际代码为例进行讲解。

从后端获取 quote 数据:

export class CommonService implements ICommonService {
  @m({ maxAge: 60 * 1000 })
  public async getQuoteList(): Promise<IQuote[]> {
    const {
      data: { list }
    } = await http({
      method: 'post',
      url: '/quote/getList',
      data: {}
    });

    return list;
  }
}

向客户端日历中同步 Note 数据:

export class NativeService implements INativeService {
  // 同步到日历
  @limit(['android', 'ios'], '1.0.1')
  public syncCalendar(params: SyncCalendarParams, onSuccess: () => void): void {
    const cb = async (errCode: number) => {
      const msg = NATIVE_ERROR_CODE_MAP[errCode];

      Vue.prototype.$toast(msg);

      if (errCode !== 6000) {
        this.errorReport(msg, 'syncCalendar', params);
      } else {
        await onSuccess();
      }
    };

    dsbridge.call('syncCalendar', params, cb);
  }
  ...
}

从 indexDB 读取某个 Note 详情数据:

import { noteTranslator } from './translators';

export class NoteService implements INoteService {
  public async get(id: number): Promise<INotebook | undefined> {
    const db = await createDB();

    const notebook = await db.getFromIndex('notebooks', 'id', id);
    return noteTranslator(notebook!);
  }
}

其中,noteTranslator 就属于 translator 层,用于订正接口返回的 note 数据,定义如下:

export function noteTranslator(item: INotebook) {
  // item.themeColor = item.color;
  return item;
}

另外我们可以拓宽下思路,当后端 API 仍在开发的时候,我们可以使用 indexDB 等本地存储技术进行模拟,建立一个 note-indexDB 服务,先提供给上层 Interactors 层进行调用,当后端 API 开发好后,就可以创建一个 note-server 服务,来替换之前的服务。只要保证前后两个服务对外暴露的接口一致,另外与上层的 Interactors 层没有过度耦合,即可实现快速切换。

Entities 层

实体 Entity 是领域驱动设计的核心概念,它是领域服务的载体,它定义了业务中某个个体的属性和方法。例如本项目中 Note 和 Notebook 都是实体。区分一个对象是否是实体,主要是看他是否有唯一的标志符(例如 id)。下面是本项目的实体 Note:

export default class Note {
  public id: number;
  public name: string;
  public deadline: Date | undefined;
  ...

  constructor(note: INote) {
    this.id = note.id;
    this.name = note.name;
    this.deadline = note.deadline;
    ...
  }

  public get isExpire() {
    if (this.deadline) {
      return this.deadline.getTime() < new Date().getTime();
    }
  }

  public get deadlineStr() {
    if (this.deadline) {
      return formatTime(this.deadline);
    }
  }
}

通过上面的代码可以看到,这里主要是以实体本身的属性以及派生属性为主,当然实体本身也可以具有方法,用于实现属于实体自身的业务逻辑(笔者认为业务逻辑可以分为两部分,一部分业务逻辑属于跟实体强相关的,应该通过在实体类中的方法实现。另一部分业务逻辑则更多的是实体之间的业务,则可以放在 Interactors 层中实现)。只是本项目中还没有涉及,在这里就不作更多说明了,有兴趣的可以参考下面列出来的笔者翻译的文章:可扩展的前端#2--常见模式(译)

另外笔者认为并不是所有的实体都应该按上面那样封装成一个类,如果某个实体本身业务逻辑很简单,就没有必要进行封装,例如本项目中 Notebook 实体就没有做任何封装,而是直接在 Interactors 层调用 Services 层提供的 API。毕竟我们做这些分层最终的目的就是理顺业务逻辑,提升开发效率,所以没有必要过于死板。

Interactors 层

Interactors 层是负责处理业务逻辑的层,主要是由业务用例组成。一般情况下 Interactor 是一个单例,它使我们能够存储一些状态并避免不必要的 HTTP 调用,提供一种重置应用程序状态属性的方法(例如:在失去修改记录时恢复数据),决定什么时候应该加载新的数据。

下面是本项目中 Common 的 Interactors 层提供的公共调用的业务:

class CommonInteractor {
  public static getInstance() {
    return this._instance;
  }

  private static _instance = new CommonInteractor(new CommonService());

  private _quotes: any;

  constructor(private _service: ICommonService) {}

  public async getQuoteList() {
    // 单例模式下,将一些基本固定不变的接口数据保存在内存中,避免重复调用
    // 但要注意避免内存泄露
    if (this._quotes !== undefined) {
      return this._quotes;
    }

    let response;

    try {
      response = await this._service.getQuoteList();
    } catch (error) {
      throw error;
    }

    this._quotes = response;
    return this._quotes;
  }
}

通过上面的代码可以看到,Sevices 层提供的类的实例主要是通过 Interactors 层的类的构造函数获取到,这样就可以达到两层之间解耦,实现快速切换 service 的目的了,当然这个和依赖注入 DI 还是有些差距的,不过已经满足了我们的需求。

另外 Interactors 层还可以获取 Entities 层提供的实体类,将实体类提供的与实体强相关的业务逻辑和 Interactors 层的业务逻辑融合到一起提供给 View 层,例如 Note 的 Interactors 层部分代码如下:

class NoteInteractor {
  public static getInstance() {
    return this._instance;
  }

  private static _instance = new NoteInteractor(
    new NoteService(),
    new NativeService()
  );

  constructor(
    private _service: INoteService,
    private _service2: INativeService
  ) {}

  public async getNote(notebookId: number, id: number) {
    try {
      const note = await this._service.get(notebookId, id);
      if (note) {
        return new Note(note);
      }
    } catch (error) {
      throw error;
    }
  }
}

当然这种分层架构并不是银弹,其主要适用的场景是:实体关系复杂,而交互相对模式化,例如企业软件领域。相反实体关系简单而交互复杂多变就不适合这种分层架构了。

在具体业务开发实践中,这种领域模型以及实体一般都是有后端同学确定的,我们需要做的是,和后端的领域模型保持一致,但不是一样。例如同一个功能,在前端只是一个简单的按钮,而在后端则可能相当复杂。

然后需要明确的是,架构和项目文件结构并不是等同的,文件结构是你从视觉上分离应用程序各部分的方式,而架构是从概念上分离应用程序的方式。你可以在很好地保持相同架构的同时,选择不同的文件结构方式。没有完美的文件结构,因此请根据项目的不同选择适合你的文件结构。

最后引用蚂蚁金服数据体验技术的《前端开发-领域驱动设计》文章中的总结作为结尾:

要明白,驱动领域层分离的目的并不是页面被复用,这一点在思想上一定要转化过来。领域层并不是因为被多个地方复用而被抽离。它被抽离的原因是:

  • 领域层是稳定的(页面以及与页面绑定的模块都是不稳定的)
  • 领域层是解耦的(页面是会耦合的,页面的数据会来自多个接口,多个领域)
  • 领域层具有极高复杂度,值得单独管理(view 层处理页面渲染以及页面逻辑控制,复杂度已经够高,领域层解耦可以轻 view 层。view 层尽可能轻量是我们架构师 cnfi 主推的思路)
  • 领域层以层为单位是可以被复用的(你的代码可能会抛弃某个技术体系,从 vue 转成 react,或者可能会推出一个移动版,在这些情况下,领域层这一层都是可以直接复用)
  • 为了领域模型的持续衍进(模型存在的目的是让人们聚焦,聚焦的好处是加强了前端团队对于业务的理解,思考业务的过程才能让业务前进)

下面推荐几篇相关文章:

前端架构-让重构不那么痛苦(译)

可扩展的前端#1--架构基础(译)

可扩展的前端#2--常见模式(译)

领域驱动设计在互联网业务开发中的实践

前端开发-领域驱动设计

领域驱动设计在前端中的应用

微前端

preload-routes

async-routes

背景介绍

对于大型前端项目,比如公司内部管理系统(一般包括 OA、HR、CRM、会议预约等系统),如果将所有业务放在一个前端项目里,随着业务功能不断增加,就会导致如下这些问题:

  • 代码规模庞大,导致编译时间过长,开发、打包速度越来越慢

  • 项目文件越来越多,导致查找相关文件变得越来越困难

  • 某一个业务的小改动,导致整个项目的打包和部署

方案介绍

preload-routes 和 async-routes 是目前笔者所在团队使用的微前端方案,最终会将整个前端项目拆解成一个主项目和多个子项目,其中两者作用如下:

  • 主项目:用于管理子项目的路由切换、注册子项目的路由和全局 Store 层、提供全局库和方法

  • 子项目:用于开发子业务线业务代码,一个子项目对应一个子业务线,并且包含两端(PC + Mobile)代码和复用层代码(项目分层中的非视图层)

结合之前的分层架构实现复用非视图代码的方式,完整的方案如下:

如图所示,将整个前端项目按照业务线拆分出多个子项目,每个子项目都是独立的仓库,只包含了单个业务线的代码,可以进行独立开发和部署,降低了项目维护的复杂度。

采用这套方案,使得我们的前端项目不仅保有了横向上(多个子项目)的扩展性,又拥有了纵向上(单个子项目)的复用性。那么这套方案具体是怎么实现的呢?下面就详细说明方案的实现机制。

在讲解之前,首先明确下这套方案有两种实现方式,一种是预加载路由,另一种是懒加载路由,接下来就分别介绍这两种方式的实现机制。

实现机制

预加载路由

preload-routes

1.子项目按照 vue-cli 3 的 library 模式进行打包,以便后续主项目引用

注:在 library 模式中,Vue 是外置的。这意味着包中不会有 Vue,即便你在代码中导入了 Vue。如果这个库会通过一个打包器使用,它将尝试通过打包器以依赖的方式加载 Vue;否则就会回退到一个全局的 Vue 变量。

2.在编译主项目的时候,通过 InsertScriptPlugin 插件将子项目的入口文件 main.js 以 script 标签形式插入到主项目的 html 中

注:务必将子项目的入口文件 main.js 对应的 script 标签放在主项目入口文件 app.js 的 script 标签之上,这是为了确保子项目的入口文件先于主项目的入口文件代码执行,接下来的步骤就会明白为什么这么做。

再注:本地开发环境下项目的入口文件编译后的 main.js 是保存在内存中的,所以磁盘上看不见,但是可以访问。

InsertScriptPlugin 核心代码如下:

compiler.hooks.compilation.tap('InsertScriptWebpackPlugin', (compilation) => {
  compilation.hooks.htmlWebpackPluginBeforeHtmlProcessing.tap(
    'InsertScriptWebpackPlugin',
    (htmlPluginData) => {
      const {
        assets: { js }
      } = htmlPluginData;
      // 将传入的 js 以 script 标签形式插入到 html 中
      // 注意:需要将子项目的入口文件 main.js 放在主项目入口文件 app.js 之前,因为需要子项目提前将自己的 route list 注册到全局上
      js.unshift(...self.files);
    }
  );
});

3.主项目的 html 要访问子项目里的编译后的 js / css 等资源,需要进行代理转发

如果是本地开发时,可以通过 webpack 提供的 proxy,例如:

const PROXY = {
  '/app-a/': {
    target: 'http://localhost:10241/'
  }
};

如果是线上部署时,可以通过 nginx 转发或者将打包后的主项目和子项目放在一个文件夹中按照相对路径引用。

4.当浏览器解析 html 时,解析并执行到子项目的入口文件 main.js,将子项目的 route list 注册到 Vue.__share__.routes 上,以便后续主项目将其合并到总的路由中。

子项目 main.js 代码如下:(为了尽量减少首次主项目页面渲染时加载的资源,子项目的入口文件建议只做路由挂载)

import Vue from 'vue';
import routes from './routes';

const share = (Vue.__share__ = Vue.__share__ || {});
const routesPool = (share.routes = share.routes || {});

// 将子项目的 route list 挂载到 Vue.__share__.routes 上,以便后续主项目将其合并到总的路由中
routesPool[process.env.VUE_APP_NAME] = routes;

5.继续向下解析 html,解析并执行到主项目 main.js 时,从 Vue.__share__.routes 获取所有子项目的 route list,合并到总的路由表中,然后初始化一个 vue-router 实例,并传入到 new Vue 内

相关关键代码如下

// 从 Vue.__share__.routes 获取所有子项目的 route list,合并到总的路由表中
const routes = Vue.__share__.routes;

export default new Router({
  routes: Object.values(routes).reduce((acc, prev) => acc.concat(prev), [
    {
      path: '/',
      redirect: '/app-a'
    }
  ])
});

到此就实现了单页面应用按照业务拆分成多个子项目,直白来说子项目的入口文件 main.js 就是将主项目和子项目联系起来的桥梁。

另外如果需要使用 vuex,则和 vue-router 的顺序恰好相反(先主项目后子项目):

1.首先在主项目的入口文件中初始化一个 store 实例 new Vuex.Store,然后挂在到 Vue.__share__.store 上

2.然后在子项目的 App.vue 中获取到 Vue.__share__.store 并调用 store.registerModule(‘app-x', store),将子项目的 store 作为子模块注册到 store 上

懒加载路由

async-routes

懒加载路由,顾名思义,就是说等到用户点击要进入子项目模块,通过解析即将跳转的路由确定是哪一个子项目,然后再异步去加载该子项目的入口文件 main.js(可以通过 systemjs 或者自己写一个动态创建 script 标签并插入 body 的方法)。加载成功后就可以将子项目的路由动态添加到主项目总的路由里了。

1.主项目 router.js 文件中定义了在 vue-router 的 beforeEach 钩子去拦截路由,并根据即将跳转的路由分析出需要哪个子项目,然后去异步加载对应子项目入口文件,下面是核心代码:

const cachedModules = new Set();

router.beforeEach(async (to, from, next) => {
  const [, module] = to.path.split('/');

  if (Reflect.has(modules, module)) {
    // 如果已经加载过对应子项目,则无需重复加载,直接跳转即可
    if (!cachedModules.has(module)) {
      const { default: application } = await window.System.import(
        modules[module]
      );

      if (application && application.routes) {
        // 动态添加子项目的 route-list
        router.addRoutes(application.routes);
      }

      cachedModules.add(module);
      next(to.path);
    } else {
      next();
    }
    return;
  }
});

2.子项目的入口文件 main.js 仅需要将子项目的 routes 暴露给主项目即可,代码如下:

import routes from './routes';

export default {
  name: 'javascript',
  routes,
  beforeEach(from, to, next) {
    console.log('javascript:', from.path, to.path);
    next();
  }
};

注意:这里除了暴露 routes 方法外,另外又暴露了 beforeEach 方法,其实就是为了支持通过路由守卫对子项目进行页面权限限制,主项目拿到这个子项目的 beforeEach,可


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
热门推荐
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap