上一篇文章我们写了MVC模式,以及说了MVC模式的缺点,这篇文章我们就来实现一个简单的MVVM模式。
Model负责管理数据,View负责管理视图,ViewModel负责数据和视图的连接。
image.png

先看一下大概的目录结构:

  1. 08-MVVM
  2. ├─ index.html
  3. ├─ mvvm
  4. ├─ index.js
  5. ├─ render.js # 负责页面的渲染
  6. ├─ compiler
  7. ├─ event.js # 负责事件处理
  8. └─ state.js # 负责数据处理
  9. ├─ reactive
  10. ├─ index.js
  11. └─ mutableHandler.js # 对数据进行响应式处理
  12. └─ shared
  13. └─ utils.js
  14. └─ src
  15. └─ App.js # 程序的入口

这个案例使用了Vite作为服务器进行开发,所以你需要进行安装Vite和配置package.json文件。

  1. {
  2. "name": "08-mvvm",
  3. "version": "1.0.0",
  4. "description": "",
  5. "main": "index.js",
  6. "scripts": {
  7. "dev": "vite"
  8. },
  9. "author": "",
  10. "license": "ISC",
  11. "devDependencies": {
  12. "vite": "^4.0.3"
  13. }
  14. }

App.js

  1. // Vite 会自动补全 /index.js 的后缀
  2. import { useDom, useReactive } from "../mvvm/index";
  3. function App() {
  4. // 创建响应式
  5. const state = useReactive({
  6. count: 0,
  7. name: "TestName"
  8. });
  9. // 操作 state 数据的方法
  10. const add = function (num) {
  11. state.count += num;
  12. };
  13. const minus = function (num) {
  14. state.count -= num;
  15. };
  16. const changeName = function (name) {
  17. state.name = name;
  18. };
  19. // 模版的渲染
  20. return {
  21. template: `
  22. <h1>{{ count }}</h1>
  23. <h2>{{ name }}</h2>
  24. <button onClick="add(2)">新增</button>
  25. <button onClick="minus(1)">减去</button>
  26. <button onClick="changeName('xiechen')">更改名字</button>
  27. `,
  28. state,
  29. methods: {
  30. add,
  31. minus,
  32. changeName
  33. }
  34. };
  35. }
  36. useDom(
  37. App(), // 返回 template,state,methods
  38. document.querySelector("#app")
  39. );

以上代码,我们引入了useDom方法对App的模版进行渲染,引入useReactivestate数据进行管理。

我们把所有需要用到的方法,都导入到了mvvm/index.js这个文件里面进行管理:

  1. export { useReactive } from "./reactive";
  2. export { useDom, update } from "./render";
  3. export { eventFormat } from "./compiler/event";
  4. export { stateFormat } from "./compiler/state";

接下来,就让我们看看每个文件都负责干了点啥。

mvvm/reactive

该文件的useReactive方法在App.js文件中进行了调用:

  1. // isObject 主要是判断是不是一个对象,如果你想看到更多的实现细节,你可以滑倒文章的最后。
  2. import { isObject } from "../shared/utils";
  3. import { mutableHandler } from "./mutableHandler";
  4. export function useReactive(target) {
  5. // target 为 App.js 中的 state , 也就是
  6. /*
  7. {
  8. count: 0,
  9. name: "TestName"
  10. }
  11. */
  12. // mutableHandler 为 Proxy 对象拦截属性的一些方法
  13. return createReactObject(target, mutableHandler);
  14. }
  15. function createReactObject(target, baseHandler) {
  16. if (!isObject(target)) {
  17. return target;
  18. }
  19. return new Proxy(target, baseHandler);
  20. }

以上代码,我们在createReactObject方法中对App.js文件中的数据进行拦截,并引入了mutableHandler.js文件对Proxy的数据进行处理。

  1. import { useReactive } from "./index";
  2. /**
  3. * hasOwnProperty 用于判断一个属性是不是对象本身上的属性,而非原型上的属性
  4. * isEqual 用于判断新值和旧值是否相等
  5. */
  6. import { isObject, hasOwnProperty, isEqual } from "../shared/utils";
  7. import { update } from "../render";
  8. import { statePool } from "../compiler/state";
  9. function createGetter() {
  10. return function get(target, key, receiver) {
  11. // 通过 Reflect.get 方法去操作属性
  12. // 详见MDN:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Reflect/get
  13. const res = Reflect.get(target, key, receiver);
  14. // 如果返回的值是一个对象,那么就继续调用 useReactive 去处理
  15. if (isObject(res)) {
  16. return useReactive(res);
  17. }
  18. // 否则直接返回
  19. return res;
  20. };
  21. }
  22. function createSetter() {
  23. return function set(target, key, value, receiver) {
  24. const isKeyExist = hasOwnProperty(target, key);
  25. const oldValue = target[key];
  26. // 同 Reflect.get ,但是 set 返回的是是否设置成功的布尔值
  27. const res = Reflect.set(target, key, value, receiver);
  28. // 如果对象上没有这个属性,那么这个属性就是新增的属性
  29. if (!isKeyExist) {
  30. console.log("响应式新增:", value);
  31. } else if (!isEqual(value, oldValue)) {
  32. // 否则就是去更改属性的值
  33. console.log("响应式修改:", key, value);
  34. // 然后调用视图的 update 方法
  35. update(statePool, key, value);
  36. }
  37. return res;
  38. };
  39. }
  40. const get = createGetter();
  41. const set = createSetter();
  42. export const mutableHandler = {
  43. get,
  44. set
  45. };

以上代码,我们分别对属性的setget拦截进行了处理,在set方法中,无论是新增或更改对象的属性,我们都可以拦截的到。

mvvm/render.js

render.js文件主要负责了对视图管理,我们在App.js文件中调用了useDom方法进行视图的渲染,且在Proxyset处理中调用了update进行视图的更新。

  1. /**
  2. * bindEvent 用于给元素绑定事件
  3. */
  4. import { bindEvent } from "./compiler/event";
  5. import { eventFormat, stateFormat } from "./index";
  6. export function useDom({ template, state, methods }, rootDom) {
  7. // 接收 App.js 方法返回的对象,也就是
  8. /*
  9. {
  10. template: xxx
  11. state: xxx
  12. methods: xxx
  13. }
  14. */
  15. // 调用 render 方法,对模版数据进行处理
  16. rootDom.innerHTML = render(template, state);
  17. // 调用 bindEvent 方法进行绑定事件
  18. bindEvent(methods);
  19. }
  20. export function render(template, state) {
  21. /*
  22. eventFormat 方法会给绑定事件的模版新增一个属性 data-mark="xxx",例如模版
  23. <button onClick="add(2)">新增</button> =>>
  24. <button data-mark="12345" onClick="add(2)">新增</button>
  25. 并且保存到一个名为 eventPool 的数组中,数据结构如下:
  26. [
  27. {
  28. mark: 12345 // dom 标签上的 data-mark
  29. handler: add(2)
  30. type: click
  31. }
  32. ]
  33. */
  34. template = eventFormat(template);
  35. /*
  36. stateFormat 方法会给标签新增一个属性 data-mark="xxx",例如模版
  37. <h1>{{ count }}</h1> =>>
  38. <h1 data-mark="12345">1</h1>
  39. 并且保存到一个名为 statePool 的数组中,数据结构如下:
  40. [
  41. {
  42. mark: 12345,
  43. state: ["count"]
  44. }
  45. ]
  46. */
  47. template = stateFormat(template, state);
  48. return template;
  49. }
  50. /*
  51. update 方法接收了 statePool 为参数,也就是
  52. [
  53. {
  54. mark: 12345
  55. state: ["count"]
  56. }
  57. ]
  58. 还接收了 set 数据的时候,要更改的属性和值
  59. */
  60. export function update(statePool, key, value) {
  61. const allElements = document.querySelectorAll("*");
  62. let oItem = null;
  63. // 进行遍历
  64. statePool.forEach((el) => {
  65. // 如果 statePool 中 el.state 中的数据等于要 set 的属性名
  66. if (el.state[el.state.length - 1] === key) {
  67. for (let i = 0; i < allElements.length; i++) {
  68. oItem = allElements[i];
  69. const _mark = parseInt(oItem.dataset.mark);
  70. // 如果 statePool.mark 等于某个节点的 data-mark 属性
  71. if (el.mark === _mark) {
  72. oItem.innerHTML = value;
  73. }
  74. }
  75. }
  76. });
  77. }

以上代码,我们分别调用了

  • bindEventDOM绑定事件。
  • eventFormatDOM和事件的对应关系进行存储。
  • stateFormatDOM和数据的对应关系进行存储,并且替换为state中对应的数据。

mvvm/compiler

以下是对mvvm/compiler/event.js文件的详解:

  1. import { checkType, randomNum } from "../shared/utils";
  2. /**
  3. * {
  4. * mark: random,
  5. * handler: 事件处理函数的字符串
  6. * type: click
  7. * }
  8. */
  9. const reg_onClick = /onClick\=\"(.+?)\"/g;
  10. const reg_fnName = /^(.+?)\(/;
  11. const reg_arg = /\((.*?)\)/;
  12. const eventPool = [];
  13. export function eventFormat(template) {
  14. return template.replace(reg_onClick, function (node, key) {
  15. const _mark = randomNum();
  16. // 把数据的对应关系存到 eventPool 里面,方面我们进行对比调用
  17. eventPool.push({
  18. mark: _mark,
  19. handler: key.trim(),
  20. type: "click",
  21. });
  22. /*
  23. eventPool 结构如下:
  24. [
  25. {
  26. mark: 12345 // dom 标签上的 data-mark
  27. handler: add(2)
  28. type: click
  29. }
  30. ]
  31. */
  32. // 给标签新增一个 data-mark="12345" 这样的属性
  33. return `data-mark="${_mark}"`;
  34. });
  35. }
  36. export function bindEvent(methods) {
  37. const allElements = document.querySelectorAll("*");
  38. let oItem = null;
  39. let _mark = 0;
  40. /*
  41. eventPool 结构如下:
  42. [
  43. {
  44. mark: 12345 // dom 标签上的 data-mark
  45. handler: add(2)
  46. type: click
  47. }
  48. ]
  49. */
  50. // 循环对比
  51. eventPool.forEach((el) => {
  52. for (let i = 0; i < allElements.length; i++) {
  53. oItem = allElements[i];
  54. _mark = parseInt(oItem.getAttribute("data-mark"));
  55. // 如果 eventPool 中 el.mark 等于某个 dom 的 data-mark 的属性
  56. if (el.mark === _mark) {
  57. // 绑定事件
  58. oItem.addEventListener(el.type, function () {
  59. const fnName = el.handler.match(reg_fnName)[1];
  60. const arg = checkType(el.handler.match(reg_arg)[1]);
  61. // 调用 state.methods 里面对应的方法
  62. methods[fnName](arg);
  63. }, false);
  64. }
  65. }
  66. });
  67. }

以下是对mvvm/compiler/state.js文件的详解:

  1. import { randomNum } from "../shared/utils";
  2. const reg_html = /\<.+?\>\{\{(.+?)\}\}\<\/.+?\>/g;
  3. const reg_tag = /\<(.+?)\>/;
  4. const reg_var = /\{\{(.+?)\}\}/g;
  5. /**
  6. * {
  7. * mark: _mark
  8. * state: value
  9. * }
  10. */
  11. export const statePool = [];
  12. let o = 0;
  13. export function stateFormat(template, state) {
  14. let _state = {};
  15. // 绑定 data-mark
  16. template = template.replace(reg_html, function (node, key) {
  17. const matched = node.match(reg_tag);
  18. const _mark = randomNum();
  19. /*
  20. _state 结构如下:
  21. {
  22. mark: 12345,
  23. }
  24. statePool 结构如下:
  25. [
  26. {
  27. mark: 12345,
  28. }
  29. ]
  30. */
  31. _state.mark = _mark;
  32. statePool.push(_state);
  33. _state = {};
  34. // 例如将 <h1>{{ count }}</h1> 替换为
  35. // <h1 data-mark="12345">{{ count }}</h1>
  36. return `<${matched[1]} data-mark="${_mark}">{{ ${key} }}</${matched[1]}>`;
  37. });
  38. // 替换模版数据
  39. template = template.replace(reg_var, function (node, key) {
  40. let _var = key.trim(); // 拿到 state 里面属性的 key
  41. const _varArr = _var.split(".");
  42. let i = 0;
  43. while (i < _varArr.length) {
  44. // 去拿 state 里面对应的数据,例如 _var 为 count,所以 state.count
  45. // 最后 _var 得到了 state.count 的值,也就是 0
  46. _var = state[_varArr[i]];
  47. i++;
  48. }
  49. _state.state = _varArr;
  50. statePool[o].state = _varArr;
  51. o++;
  52. /*
  53. statePool 的结构如下:
  54. [
  55. {
  56. mark: 12345
  57. state: ["count"]
  58. }
  59. ]
  60. */
  61. // 将 <h1 data-mark="12345">{{ count }}</h1> 中的 count 替换为真实的数据
  62. return _var;
  63. });
  64. return template;
  65. }

shared/utils.js

utils.js文件主要存放的是一些工具类的方法,我们在上面案例使用到的方法,在这里都可以找得到。

  1. function isObject(val) {
  2. return typeof val === "object" && val !== null;
  3. }
  4. function hasOwnProperty(target, key) {
  5. return Object.prototype.hasOwnProperty.call(target, key);
  6. }
  7. function isEqual(newVal, oldValue) {
  8. return newVal === oldValue;
  9. }
  10. function randomNum() {
  11. return new Date().getTime() + parseInt(Math.random() * 10000);
  12. }
  13. function checkType(str) {
  14. const reg_check_str = /^[\'\"](.*?)[\'\"]/;
  15. const reg_str = /(\'|\")/g;
  16. if (reg_check_str.test(str)) {
  17. return str.replace(reg_str, "");
  18. }
  19. switch (str) {
  20. case "true":
  21. return true;
  22. case "false":
  23. return false;
  24. default:
  25. break;
  26. }
  27. return Number(str);
  28. }
  29. export { isObject, hasOwnProperty, isEqual, randomNum, checkType };

收尾

到这里,我们就把这个模式完整的解释完了,再回顾一下这个代码的结构,
image.png
屏幕录制2023-02-07 15.11.11.gif

这样我们就只负责数据和视图的逻辑,剩下的事情全部交给mvvm驱动去管理,mvvm负责了创建响应式数据、对事件和数据进行编译、对模版进行渲染以及视图更新。

源码地址:
https://github.com/xiechen1201/JSPlusPlus/blob/main/%E8%85%BE%E8%AE%AF%E8%AF%BE%E5%A0%82/Vue%20%E6%9C%AC%E5%B0%8A01/08-MVVM/src/App.js