Meet Meteor: "一站式"全栈 Javascript 开发框架

4,907 阅读10分钟

前言

Meteor是一款有10年历史基于NodeJs的全栈开发框架,他使用独特的DDP协议将服务端的Mongo和浏览器的Mini Mongo进行数据发布和同步,同时支持Web、Mobile、Desktop三端,给开发人员提供了快捷独特的开发体验。

Meteor 是什么

Meteor 是一款基于 NodeJS 的全栈开发框架,即 Meteor 包括了服务端和前端,在前端 Meteor 可无缝对接当前流行的前端开发框架(Angular/Vue/React);同时使用 Meteor 开发的应用可以在 Web、Mobile、Desktop 三端运行;在后端 Meteor 基于 NodeJs,同时使用 Mongo 这一 NoSQL 数据库作为默认 DB 存储。Meteor 和Mongo 是深度集成的,除了默认使用 Mongo 作为 DB 存储,在前端 Meteor 有 Mini Mongo 的概念,即可以任务在前端,数据交互也是直接和浏览器上的 Mongo 进行相关的,Meteor 负责后端 Mongo 和 前端 Mini Mongo 的数据发布和同步。

Meteor 的特点

如果要找出来 Meteor 框架的特点,以下两点可以说不可不提:

  • 一站式:基于 Meteor 开发的应用程序可以运行在 Web、Mobile(Android/iPhone)、和 Desktop 三种终端上
  • 与其他开发框架无缝集成:Meteor 不是类似 React/Vue 那样的前端框架,相反,Meteor 可以无缝集成 React/Vue/Svelte 等框架,这意味着使用 Meteor 框架的时候,可以挑选任务熟悉的前端框架比如 React 或者 Svelete。
  • Mongo 的使用,以及 Mongo Collection 的发布和数据同步, Meteor 的 Distributed Data Protocol DDP 协议,DDP 复制将服务端的 Mongo 和浏览器中的 mini Mongo 的数据双向同步,开发者在前端不需要关心数据的获取和保存

Meteor Todo-List 应用示例

创建应用

在安装完 Meteor 框架后,可以使用 Meteor 命令来创建应用

meteor create my-todos-sample

这里默认使用 React 作为前端开发框架,如需使用 Vue/Svelte 等框架,需要加上 --vue / --svelte 等参数
创建应用后,一个基本的 Meteor 应用就基本创建成功了

  • 其中 client 目录,包含了 html 文件和前端 js 入口,会使用 React 渲染应用的 App 组件
import React from 'react';
import { Meteor } from 'meteor/meteor';
import { render } from 'react-dom';
import { App } from '/imports/ui/App';

Meteor.startup(() => {
  render(<App/>, document.getElementById('react-target'));
});
  • 其中 serve 目录,则负责服务端的初始化和启动
import { Meteor } from "meteor/meteor";

Meteor.startup(() => {
  // ...
});
  • imports/ui 目录下包含了项目中的 React 组件定义
  • imports/api 目录则包含了 Mongo Collection 的定义,可以理解 Meteor 中的 Collection 为传统程序的表或者ORM model,可以为 Collection 定义 Schema,然后用来存储和查询数据;并且,Meteor 的 Collection 既可以运行在服务端,也可以运行在浏览器中。

使用 meteor run命令既可启动开发环境,在浏览器中查看效果了

定义 collection

在创建完应用之后,我们来创建一个 collection 来定义和存储 todo 任务了,这里我们定一个名为 tasks 的 collection:

// 文件 /imports/api/task.js
import { Mongo } from "meteor/mongo";

export const TasksCollection = new Mongo.Collection("tasks");

然后在 server 中引入 task.js 文件,Meteor 即会自动在 Mongo 中帮助我们创建好 tasks collection,当然也可以在 server 中来初始化插入一些数据,如在 server 启动代码中,如果查询到 tasks collection 中没有记录,这自动查如一些数据:

// file: server/main.js
import { Meteor } from "meteor/meteor";
import { TasksCollection } from "/imports/api/tasks";

function insertTask(text) {
  TasksCollection.insert({ text });
}

Meteor.startup(() => {
  // TasksCollection.remove({});
  if (TasksCollection.find().count() === 0) {
    [
      "First Task",
      "Second Task",
      "Third Task",
      "Fourth Task",
      "Fifth Task",
      "Sixth Task",
      "Seventh Task",
    ].forEach(insertTask);
  }
});

在使用 meteor run 命令启动应用程序后,可以使用 NoSqlBooster 等 GUI 工具查看到 MongoDB 中的数据了:
image.png

在前端渲染tasks

上面我们完成了 tasks collection 的定义和测试数据的插入,那怎么在前端获取这些数据并进行渲染呢?Meteor 对 React 框架提供了 meteor-react-data 包让开发者在 React 前端中获取 collection 中的数据。首先需要将 react-meteor-data package 安装在应用中,同时可以使用 meteor 命令

meteor add react-meteor-data

首先定义一个 Task React 组件来渲染每一个 task 的内容:

import React from "react";

export const Task = ({ task }) => {
  return <li>{task.text}</li>;
};

然后在 App.jsx 中,使用 react-meteor-data 提供的 useTracker hook 来使用 colllection 中的数据:

import React from "react";
import { useTracker } from "meteor/react-meteor-data";
import { TasksCollection } from "/imports/api/tasks";
import { Task } from "./Task";

export const App = () => {
  const tasks = useTracker(() => TasksCollection.find({}).fetch());

  return (
    <div>
      <h1>Welcome to Meteor!</h1>

      <ul>
        {tasks.map((task) => (
          <Task key={task._id} task={task} />
        ))}
      </ul>
    </div>
  );
};

可以看到,Meteor 已经自动hot refresh,在页面上显示出了我们插入的测试数据了:image.png

创建todo任务

首先我们定一个表单组件来提供创建任务的功能

// file: /imports/ui/TaskForm.jsx
import React, { useCallback, useState } from "react";
import { TasksCollection } from "/imports/api/TasksCollection";

export const TaskForm = () => {
  const [text, setText] = useState("");

  const onSubmit = useCallback((evt) => {
    evt.preventDefault();
    if (!text) {
      return;
    }

    TasksCollection.insert({
      text: text.trim(),
      createdAt: new Date(),
    });
  }, [text]);

  return (
    <form className="task-form" onSubmit={onSubmit}>
      <input
        type="text"
        placeholder="Insert to add item"
        value={text}
        onChange={(evt) => {
          setText(evt.target.value);
        }}
      />
      <button type="submit">Add Task</button>
    </form>
  );
};

可以看到,在浏览器端,用户新建的任务也是通过 TasksCollections 来进行数据的插入,使用方式和在 server 端是没有区别的。
同时可以看到,在点击添加时候,todo任务列表就同时更新显示了最新的todo任务了,那新建的任务是否有保存到服务端呢?答案是肯定的,可以再次使用 NoSqlBooster 查看 MongoDB 中的数据已经更新了。

更新任务

到目前为止,我们已经可以创建任务了,这里我们来给任务加一个 checkbox,这样用户可以标记任务是否完成了,更新 Task 组件:

import React from "react";

export const Task = ({ task, onCheckboxClick }) => {
  return (
    <li>
      <input
        type="checkbox"
        checked={!!task.isChecked}
        onClick={() => onCheckboxClick(task)}
      />
      <span>{task.text}</span>
    </li>
  );
};

然后在 App 组件中给 Task 组件添加属性:

import React from "react";
import { useTracker } from "meteor/react-meteor-data";
import { TasksCollection } from "/imports/api/tasks";
import { Task } from "./Task";
import { TaskForm } from "./TaskForm";

const toggleChecked = ({ _id, isChecked }) => {
  TasksCollection.update(_id, {
    $set: {
      isChecked: !isChecked,
    },
  });
};

export const App = () => {
  const tasks = useTracker(() =>
    TasksCollection.find({}, { sort: { createdAt: -1 } }).fetch()
  );

  return (
    <div>
      <h1>Welcome to Meteor!</h1>

      <TaskForm />

      <ul>
        {tasks.map((task) => (
          <Task key={task._id} task={task} onCheckboxClick={toggleChecked} />
        ))}
      </ul>
    </div>
  );
};

可以看到,在修改todo 任务的 isChecked 属性时,只需要使用 TasksCollection.update 操作即可,和创建任务一样,这里的修改操作也是实时保存到 MongoDB 中的。

删除任务

同样,我们也可以很容易的支持任务删除操作,给 Task 组件添加删除按钮
image.png
在 App 组件中,这使用 TasksCollection.remove 函数来完成任务的删除:

const deleteTask = ({ _id }) => {
  TasksCollection.remove(_id);
};

export const App = () => {
  const tasks = useTracker(() =>
    TasksCollection.find({}, { sort: { createdAt: -1 } }).fetch()
  );

  return (
    <div>
      <h1>Welcome to Meteor!</h1>

      <TaskForm />

      <ul>
        {tasks.map((task) => (
          <Task
            key={task._id}
            task={task}
            onCheckboxClick={toggleChecked}
            onDeleteClick={deleteTask}
          />
        ))}
      </ul>
    </div>
  );
};

过滤任务

这里我们来添加一个按钮来过滤已经完成的任务,或者显示全部的任务功能。首先,给 App 组件添加过滤按钮:

<div className="filter">
  <button onClick={() => setHideCompleted(!hideCompleted)}>
    {hideCompleted ? "Show All" : "Hide Completed"}
  </button>
</div>

其次,在使用 TasksCollections.find 来查询任务的时候,需要根据 hideCompleted 标记来进行筛选:

  const [hideCompleted, setHideCompleted] = useState(false);
  const tasks = useTracker(() =>
    TasksCollection.find(hideCompleted ? hideCompletedFilter : {}, {
      sort: { createdAt: -1 },
    }).fetch()
  );

移动端 & 桌面端

Meteor 提供了移动端和桌面端功能,使用Meteor框架开发的应用,可直接生成移动端和桌面端应用,这里以 iOS 应用为例,来把我们的 TODO 应用生成 iOS 应用。
首先添加 iOS 支持:

meteor add-platform ios

然后就可 iOS 模拟器中运行了:

meteor run ios

可以看到 meteor 自动帮我们完成了 iOS 的打包编译,并直接在模拟器中可以看到 iOS 应用了。事实上,可以同时打开 web 端和 iOS 端,并进行 todo 任务的添加更新和删除,可以看到,Web 端和 iOS 端的数据竟然也实时的同步了,这是因为不管是 Web 端还是 iOS 端,它们的 Mini Mongo 数据都是和服务端的 Mongo DB 中的数据是实时同步的。
2022-09-15 21-07-15.2022-09-15 21_11_08.gif

数据发布和更新

到目前为止,在上面的示例中,无论是在服务端,还是在客户端,我们对数据的操作都是直接使用 Mongo Collection 来直接更新 MongoDB 了,这样做存在两个问题:

  1. 客户端对 MongoDB 的操作可能是很危险的,比如这样是没有权限控制等检查的,即在客户端也是可以直接修改 mini Mongo 数据并且是实时同步提交到服务端 Mongo了
  2. 服务端 Mongo 的数据和客户端的数据是自动同步的,这样可能造成不必要的数据传输,或者敏感数据传输到了客户端

为了解决上面两个问题,Meteor 分别提供了两个功能。

Meteor Methods

Meteor methods 一种让客户端安全地更新 Mongo 数据的机制,可以通过声明 Method 方法来进行数据更新
首先,移除 insercure 包来禁止客户端直接更新 Mongo 数据:

meteor remove insecure

然后,将数据的更新操作声明在 method 中:

import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { TasksCollection } from './TasksCollection';
 
Meteor.methods({
  'tasks.insert'(text) {
    check(text, String);

    if (!this.userId) {
      throw new Meteor.Error('Not authorized.');
    }

    TasksCollection.insert({
      text,
      createdAt: new Date,
      userId: this.userId,
    })
  },

  'tasks.remove'(taskId) {
    check(taskId, String);

    if (!this.userId) {
      throw new Meteor.Error('Not authorized.');
    }

    TasksCollection.remove(taskId);
  },

  'tasks.setIsChecked'(taskId, isChecked) {
    check(taskId, String);
    check(isChecked, Boolean);
 
    if (!this.userId) {
      throw new Meteor.Error('Not authorized.');
    }

    TasksCollection.update(taskId, {
      $set: {
        isChecked
      }
    });
  }
});

同时需要在服务端执行注册代码:

// file: server/main.js
import { Meteor } from "meteor/meteor";
import { TasksCollection } from "/imports/api/tasks";
import "/imports/api/taskMethods";

客户端在需要更新的时候执行注册好的 method 即可:

import { Meteor } from 'meteor/meteor';
import React, { useState } from 'react';

export const TaskForm = () => {
  const [text, setText] = useState('');

  const handleSubmit = e => {
    e.preventDefault();

    if (!text) return;

    Meteor.call('tasks.insert', text);

    setText('');
  };
  ..
};

那么通过 meteor methods 执行的数据更新和直接调用 TasksCollection 进行数据更新的区别是什么呢?
实际上在通过 method 进行数据更新时,Meteor 会负责整个过程:

  • 客户端发生请求端到服务端,由服务端在相对安全的环境中来执行 method 中的代码,并进行校验
  • 同时客户端模拟更新本地数据并立即更新 UI
  • 但服务端执行返回新数据后,会与本地数据进行对比,如果有差异,会对本地数据进行修改,并触发 UI 更新

Meteor 对比传统的 HTTP REST API 有哪些优点呢:

  • Meteor.call 的是使用方式是同步API调用,但不会 block JS 线程
  • Meteor 的 methods 的执行默认时有序的:即后发送的 method 调用会在前面 methods 调用执行返回完成之后才会执行,不会像多个 REST API 调用存在乱序的问题;当然这个特性时是可以在全局或者在 method 级别调整的
  • Method 方式会先更新本地UI,在服务端返回会进行修正,这意味着更加优化的UI体验,用户不会观察到数据更新的延迟

数据发布和订阅

在上面的例子中,服务端的 Mongo 数据和客户端的数据是实时自动同步的,这样会把全量的数据进行同步;在很多时候,我们不希望把全量的数据同步到客户端,比如不同用户只能看到自己的数据等情况,这里时候就可以使用 Meteor 的数据发布功能了。
首先,需要把自动发布功能关闭:

meteor remove autopublish

禁用掉数据自动发布后,刷新页面,可以看到已经看不到我们之间添加的todo任务了,这是因为禁用了自动发布后,服务端的MongoDB 数据就不会自动同步到客户端了。这时候就需要手动来维护数据的发布和订阅了。
首先,在服务端来进行数据发布,新建 tasksPublications.js 文件,并在 server/index.js 中引入执行:注意在这里发布数据的时候,就可以对数据进行过滤了

import { Meteor } from "meteor";
import { TasksCollection } from "/imports/db/tasks";

Meteor.publish("tasks", function publishTasks() {
  return TasksCollection.find({});
});

其次,需要在客户端订阅数据,订阅任务只需要在 useTracker 中调用 Meteor.subscribe 即可:

    const handler = Meteor.subscribe("tasks");

同时还可以通过 handler.ready 来判断订阅数据是否完成

  const { tasks, isLoading } = useTracker(() => {
    const handler = Meteor.subscribe("tasks");
    if (!handler.ready()) {
      return { tasks: [], isLoading: true };
    }

    const tasks = TasksCollection.find(
      hideCompleted ? hideCompletedFilter : {},
      { sort: { createdAt: -1 } }
    ).fetch();

    return { tasks, isLoading: false };
  });

当然也可以在 React 组件挂载后来进行订阅:

  useEffect(() => {
    Meteor.subscribe("tasks");
  }, [])

Data Distributed Protocol

无论是 Todo-List 示例应用,还是去掉自动发布后的手动发布和订阅,这背后都是 Meteor 的 DDP 来帮做我们来在服务端和客户端进行数据传输。
在传统的应用的,服务端和客户端通常通过 REST API 来交互,即通常是客户端发送请求,服务端响应后发送返回结果给客户端,并且通常情况下服务端是不能主动推送数据给客户端的。
Meteor 则是构建在 Distributed Data Protocol(DDP) 协议之上,允许服务端和客户端进行双向的数据传递,在使用 Meteor 构建应用的时候,在服务端开发者不需要手动来构建 REST API 访问点来响应客户端请求,而是在服务端创建 publication 来推送数据到客户端。
在客户端,开发者者只需要订阅已经在服务端注册过的 publications 即可,客户端在订阅后,初始第一个全量的数据会立即同步完成,后续的数据更新也会从服务端推送到客户端。可以看到,数据的发布和订阅就是端 MongoDB 数据和客户端 MiniMongo 之间数据同步的桥梁。
MiniMongo 是 Meteor 框架在客户端使用 JavaScript 实现的 MongoDB API,它在客户端内存中模拟实现了部分的 MongoDB 功能。

Meteor 的 DDP 协议运行在 WebSocket 连接上,为 Meteor 应供提供两个功能:

  • 客户端对服务端发起的远程调用
  • 客户端对文档数据进行订阅,服务端在文档数据发生变化时通知客户端

在一个典型的 Meteor 应用中,通过会包括 DDP 协议的几下几个部分,这里分别用

连接建立

DDP 的连接通常是客户端向服务端发起的,连接建立成功之后,会获得当前的一个 session 信息

心跳

在连接建立后,服务端或者客户端任何一段都可以发起心跳检测,另外一个则需要进行回应,每一个心跳信息都待有一个唯一ID,在响应心跳时会带上同一个ID

数据管理

数据管理部分则实现了数据发布和服务端向客户端推送数据变更的功能

远程调用

在数据管理中,主要完成了客户端向服务端订阅数据,和服务端数据到客户端数据的同步,那客户端修改数据,即客户端调用 Methods 在过程在 DDP 协议中是如何实现的呢,这就是 DDP 协议的远程调用来负责的了,客户端在调用 Methods 的时候,实际是 DDP 中执行了远程调用的过程,在没有禁用 insecure 使用数据自动发布的时候,实际是 Meteor 自动的注册了所有的 Methods ,并在客户端替我们执行了 Methods。

总结

整体来说,Meteor 的理念很新颖和先进,同时对周边工具和UI框架做了很好的整合;使用 Meteor 开发可以不考虑服务端和客户端将的 API 交互和传输,专注于应用功能的开发;同时 Meteor 和周边 UI 框架和移动端工具都有很好的集成,可以选择任意流行的 UI 框架来进行 UI 开发,可以快速的生成移动端和桌面端应用。