从 koa 到 mini-vite(三)资源处理及模块依赖和热更新

122 阅读8分钟

从 koa 到 mini-vite(三)资源处理及模块依赖和热更新

这个是仓库地址

Readme Card

1、处理图片资源的路径

我们先对资源路径进行处理,不过reactvue的资源处理大有不同,我们这里先论证react,你可以从cyber文件中看到vue是如何处理的;

//..........
import koaStatic from "koa-static";
//..........
const serverContext: ServerContext = {
  root: process.cwd(),
  PluginContainer,
  app,
  plugins: plugins,
  moduleGraph,
  ws,
  clientPath,
  watcher,
};
bindingHMREvents(serverContext);
app.use(koaStatic(path.join(process.cwd(), "/src/client/")));
for (const plugin of plugins) {
  if (plugin.configureServer) {
    await plugin.configureServer(serverContext);
  }
}
app.use(indexHtmlMiddware(serverContext));
app.use(transformMiddleware(serverContext));
//..........

我们这边使用koa-static静态资源包对其客户端文件进行代理/src/client/;

react的资源处理就完成了,但是也是有其局限性的,无法对其相对路径进行处理。我在cyber.ts中,写了一个简单的 demo,使其vue实现了对相对路径的处理,你可以参考一下。

我简单的介绍一下 vue 我是如何处理的;

  1. 我通过观察得到,在vue/compiler-sfc的处理中,会将src请求的url,转换为import路径,然后使用虚拟node,对其进行处理,见图 转存失败,建议直接上传图片文件
  2. 解析出来的路径是一个import,我们通过resolve方法将其路径改写为基于服务器的绝对路径
  3. 我们的图片可不能使用import引入,我们暴露一个虚拟模块
  4. 改写路径时在后面加上一个?import符号
  5. 在引入时,我们解析此路径,返回code
export default "src/cyber/components/171536_380.png";

这样我们就能引入图片,并实现引入图片的懒加载了。

2、处理 Svg 资源

对于 svg 资源的处理,我在这里书写了一个plugin,在书写一个程序前,确立两点有助于帮助我们快速的实现

  1. 确定输入的是什么
  2. 确定输出的是什么

实际上也就是确定I/O; 我们来看看,我们需要怎么去实现一个 svg 的处理,先看需求代码

import "./App.css";
import React from "react";
import logo from "./logo.svg";
// import logo from "./logo.svg";
function App() {
  return (
    <div className="App">
      <header className="App-header">
        <p>dsdsHell ssf sadsf</p>
        <p></p>
        <img src="/public/OIP-C.jpg" alt="" />
        <p>
          ds<code>sdssdafsssfadsa</code> sans save es tsest.
        </p>
        <img className="App-logo" src={logo} alt="" />
        <p></p>
      </header>
    </div>
  );
}

我们可以清晰的看到,我们的 src 值是引入的logosrc接受的是地址,我们需要处理的是将 logo 转化为export default 真实路径;而vite插件中,load钩子是返回code的,我们就在此处做文章

import { Plugin } from "./plugin";
import { ServerContext } from "../../index";
import { cleanUrl, getShortName, normalizePath, removeImportQuery } from "../utils";
import path from "path";
export function assetPlugin(): Plugin {
  let serverContext: ServerContext;

  return {
    name: "m-vite:asset",
    configureServer(s) {
      serverContext = s;
    },
    async load(id) {
      const cleanedId = removeImportQuery(cleanUrl(id));
      const resolvedId = `/${path
        .relative(path.join(serverContext.root, "/src/client/"), cleanedId)
        .replaceAll("\\", "/")}`;

      if (cleanedId.endsWith(".svg")) {
        return {
          code: `export default "${resolvedId}"`,
        };
      }
    },
  };
}

我们在 load 中将传入的id,由经resolve的处理,我们的id是绝对路径,能读取到位置的绝对路径,后续再使用path.relative,将路径做相对路径处理,这样我们就能得到相对于当前文件的位置,因为我们在前文koa-static代理的路径是/src/client/,所以我们需要在此处进行一下处理

3、css 处理

我们css的处理用一个plugin,我们来确定一下思路,我们的目的是将import "./index.css";转化为浏览器可读的style样式;我们来梳理一下处理思路;

  1. 将 css 文本进行读取
  2. 使用 style 标签注入 css
  3. 将 style 标签注入html

这里是cssplugin 代码,后续会为了热更新我们会进行进一步的改善

// plugins/css.ts
import { readFile } from "fs-extra";
import { Plugin } from "../plugin";

export function cssPlugin(): Plugin {
  return {
    name: "m-vite:css",
    load(id) {
      // 加载
      if (id.endsWith(".css")) {
        return readFile(id, "utf-8");
      }
    },
    // 转换逻辑
    async transform(code, id) {
      if (id.endsWith(".css")) {
        // 包装成 JS 模块
        const jsContent = `
const css = "${code.replace(/\n/g, "")}";
const style = document.createElement("style");
style.setAttribute("type", "text/css");
style.innerHTML = css;
document.head.appendChild(style);
export default css;
`.trim();
        return {
          code: jsContent,
        };
      }
      return null;
    },
  };
}

4、依赖图谱

依赖图谱主要有以下几个作用

  1. 构建依赖关系
  2. 进行缓存
  3. 热更新的索引映射

我们来确定一下,我们的依赖图谱每一个节点需要的是哪些参数,这里提供一个class,我们来进行参考

export class ModuleNode {
  // 资源访问 url
  url: string;
  // 资源绝对路径
  id: string | null = null;
  importers = new Set<ModuleNode>();
  importedModules = new Set<ModuleNode>();
  transformResult: TransformResult | null = null;
  lastHMRTimestamp = 0;
  constructor(url: string) {
    this.url = url;
  }
}

我们可以看到,一个 moduleNode 类,需要的是

  • url请求连接
  • id模块的绝对路径
  • importers引入者(该id被谁引入)
  • importedModules被引入者(该id引入了哪些模块)
  • transformResult代码片段
  • lastHMRTimestamp时间戳,用于判断是否需要重新编译

此类,在后续的生成中,我们命名为mod节点,我们从urlimportAnalysis中间件,来看看他是如何处理的

首先,我们从 url 的中间件开始

中间件流程图.png

这里有张流程图,我们的代码处理如下

//src\node\middlewares\transformMiddleware.ts
//........
async function transformRequest(url: string, serverContext: ServerContext) {
  const { PluginContainer, moduleGraph } = serverContext;
  let query = url.split("?")[1];

  url = cleanUrl(url);

  let mod = await moduleGraph.getModuleByUrl(url);
  if (mod && mod.transformResult && !query) {
    return mod.transformResult;
  }
  let res;
  let resolveId = await PluginContainer.resolveId(url);
  if (resolveId?.id) {
    let code = await PluginContainer.load(resolveId?.id);
    if (typeof code === "object" && code !== null) {
      code = code.code;
    }

    mod = await moduleGraph.ensureEntryFromUrl(url);
    if (code) {
      res = await PluginContainer.transform(code, resolveId?.id);
    }
    if (mod) {
      mod.transformResult = res;
    }
  }
  return res;
}
//........

我们的moduleGraph对象会一点一点的进行介绍,一下子说完会感觉有点乱, 我们来看看,他这里调用的getModuleByUrl方法是什么

//src\node\ModuleGraph.ts
//moduleGraph.getModuleByUrl(url);
export class ModuleGraph {
  urlToModuleMap = new Map<string, ModuleNode>();
  idToModuleMap = new Map<string, ModuleNode>();
  constructor(private resolveId: (url: string) => Promise<PartialResolvedId | null>) {}
  getModuleById(url: string): ModuleNode | undefined {
    return this.idToModuleMap.get(url);
  }
  async getModuleByUrl(rawUrl: string): Promise<ModuleNode | undefined> {
    const { url } = await this._resolve(rawUrl);
    return this.urlToModuleMap.get(cleanUrl(url));
  }
  async ensureEntryFromUrl(rawUrl: string): Promise<ModuleNode | undefined> {
    const { url, resolvedId } = await this._resolve(rawUrl);
    if (this.urlToModuleMap.has(url)) {
      return this.urlToModuleMap.get(url) as ModuleNode;
    }
    const mod = new ModuleNode(url);
    mod.id = resolvedId;
    this.urlToModuleMap.set(url, mod);
    this.idToModuleMap.set(resolvedId, mod);
    return mod;
  }
}

//src\index.ts
const moduleGraph = new ModuleGraph(url => PluginContainer.resolveId(url));

这个类的constructor的参数传入的是插件容器定义的 resolveId 路径解析方法 他会帮助我们解析路径,返回由插件容器解析的路径;

  • getModuleById 通过 id 寻找模块
  • getModuleByUrl 通过 url 转换成 id 寻找模块
  • ensureEntryFromUrl 通过 url 转换成 id 寻找模块,如果存在则获取模块,如果不存在,则创建一个模块,并添加到模块依赖的缓存中

我们从流程图上来看:

  1. 判断是否存在于模块依赖的缓存中
  2. 如果存在,则从缓存中提取模块依赖(不会再重复相同的文件进行重复读写,esbuild 进行读写和修改 import 引入是及其heavy
  3. 如果不存在,则再load后,使用ensureEntryFromUrl判断是否存在该缓存,如果存在则获取模块,如果不存在,则创建一个模块,并添加到模块依赖的缓存中
  4. 对于转换好的code,则写入缓存中

importAnalysis预设进行依赖收集

// src\node\plugins\importAnalysis.ts

//.....length
async transform(code, id) {
      const { moduleGraph } = serverContext;
      const curmod = moduleGraph.getModuleById(id)!;
      const importedModules = new Set<string>();
      //....
        for (const importInfo of imports) {
        const { s: modStart, e: modEnd, n: modSource } = importInfo;
        if (!modSource) continue;
        //....
        if (BARE_IMPORT_RE.test(modSource as string)) {
          const bundlePath = normalizePath(
            path.join('/', PRE_BUNDLE_DIR, `${modSource}.js`)
          );
          importedModules.add(bundlePath);

          ms.overwrite(modStart, modEnd, bundlePath as string)
        } else if (modSource.startsWith(".") || modSource.startsWith("/")) {
        //....
          if (resolved) {
            ms.overwrite(modStart, modEnd, resolved);
            importedModules.add(resolved);
          }
        }
      };
      //....
      moduleGraph.updataModuleInfo(curmod, importedModules);
        return {
        code: ms.toString(),
        map: ms.generateMap()
      }
}


importAnalysis依赖分析.png 我们跟着流程图看

  1. 通过getModuleById获取前文的mod模块
  2. imports的依赖分析中收集子模块数组
  3. 使用moduleGraph.updataModuleInfo方法对依赖进行标记
  4. 确保依赖是否存在,如果存在则获取,不存在则新建,并将依赖写入map中
  5. 在当前mod上写入dep作为importedModules
  6. dep上写入引入mod作为源头importers
  7. mod上一次的importedModules是否存在于当前传入的模块数组中,如果不存在,则删除
export class ModuleGraph {
  urlToModuleMap = new Map<string, ModuleNode>();
  idToModuleMap = new Map<string, ModuleNode>();
  //....

  async updateModuleInfo(mod: ModuleNode, importedModules: Set<string | ModuleNode>) {
    const prevImports = mod.importedModules;
    for (const curImports of importedModules) {
      const dep =
        typeof curImports === "string"
          ? await this.ensureEntryFromUrl(cleanUrl(curImports))
          : curImports;
      if (dep) {
        mod.importedModules.add(dep);
        dep.importers.add(mod);
      }
    }
    // 清除已经不再被引用的依赖
    for (const prevImport of prevImports) {
      if (!importedModules.has(prevImport.url)) {
        prevImport.importers.delete(mod);
      }
    }
  }
}
//....

这里是完整的ModuleGraph代码,可以参考一下,具体更改的文件过多,请于仓库中查看

import { PartialResolvedId, TransformResult } from "rollup";
import { cleanUrl } from "./utils";
import { debug } from "console";
import { getboundaries } from "./plugins/hmr/boundaries"
export class ModuleNode {
  url: string = "";
  id: string = "";
  importers = new Set<ModuleNode>();
  importedModules = new Set<ModuleNode>();
  transformResult: TransformResult | null = null;
  lastHMRTimetamp = 0;
  constructor(url: string) {
    this.url = url
  }
};
export class ModuleGraph {
  urlToModuleMap = new Map<string, ModuleNode>();
  idToModuleMap = new Map<string, ModuleNode>();
  constructor(private resolveId: (url: string) => Promise<PartialResolvedId | null>) { };
  getModuleById(url: string): ModuleNode | undefined {
    return this.idToModuleMap.get(url)
  }
  async getModuleByUrl(rawUrl: string): Promise<ModuleNode | undefined> {
    const { url } = await this._resolve(rawUrl);
    return this.urlToModuleMap.get(cleanUrl(url))
  }
  async ensureEntryFromUrl(rawUrl: string): Promise<ModuleNode | undefined> {

    const { url, resolvedId } = await this._resolve(rawUrl);
    if (this.urlToModuleMap.has(url)) {
      return this.urlToModuleMap.get(url) as ModuleNode
    }
    const mod = new ModuleNode(url);
    mod.id = resolvedId;
    this.urlToModuleMap.set(url, mod);
    this.idToModuleMap.set(resolvedId, mod)
    return mod;
  };
  async updataModuleInfo(mod: ModuleNode, importedModules: Set<string | ModuleNode>) {
    const prevImports = mod.importedModules;

    for (const curImports of importedModules) {


      const dep = typeof curImports === "string" ? await this.ensureEntryFromUrl(cleanUrl(curImports)) : curImports;
      if (dep) {

        mod.importedModules.add(dep);
        dep.importers.add(mod);
      }
    }
    for (const preImport of prevImports) {
      // console.log(importedModules.has(preImport.url as string))
      if (!importedModules.has(preImport.url as string)) {
        preImport.importers.delete(mod)
      }
    }
  };
  invalidateModule(file: string) {
    const mod = this.idToModuleMap.get(file);
    if (mod) {
      mod.lastHMRTimetamp = Date.now();
      mod.transformResult = null;
      mod.importers.forEach((importer) => {
        this.invalidateModule(importer.id!)
      })
    }
  }
  private async _resolve(url: string): Promise<{ url: string, resolvedId: string }> {
    const resolved = await this.resolveId(url);
    const resolvedId = resolved?.id as string;
    return { url, resolvedId }
  }
  getboundaries(url: string) {
    let curmod = this.urlToModuleMap.get(url)!;

    return Array.from(getboundaries(curmod, new Set()))
  }
}

5、热更新

热更新这里还有一些问题值得思考,我会带大家实现一个无状态保留的热更新,具体的热更新政策,我们还是需要去读react或者vue的热更新源码,这里就不做详细介绍。 我们来确定,热更新需要的一些物料

  1. 使用ws像客户端传递信息
  2. 使用chokidar监听文件更新

热更新整体流程图如下

hmr大纲.png

//src\index.ts
const watcher = chokidar.watch(clientPath, {
  ignored: [/node_modules/, /\.git/],
  ignoreInitial: true
});
const ws = createWebSocketServer(app);
const serverContext: ServerContext = {
  root: process.cwd(),
  PluginContainer,
  app,
  plugins: plugins,
  moduleGraph,
  ws,
  clientPath,
  watcher
};
bindingHMREvents(serverContext)

我们先来看看webSokect

//src\node\ModuleGraph.ts
import { WebSocketServer, WebSocket } from "ws";
import { HMR_PORT } from "./contants";
import color from "picocolors";
export function createWebSocketServer(server: any): { send: (msg: string) => void; close: () => void } {
  let wss: WebSocketServer;
  wss = new WebSocketServer({ port: HMR_PORT });
  wss.on("connection", (socket) => {
    socket.send(JSON.stringify({ type: "connected" }))
  });
  wss.on("error", (e: Error & { code: string }) => {
    if (e.code !== "EADDRINUSE") {
      console.error(color.red(`WebSocket server error:\n${e.stack || e.message}`))
    }
  })
  return {
    send(payload: Object) {
      const stringified = JSON.stringify(payload);
      wss.clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
          client.send(stringified)
        }
      })
    },
    close() { 
      wss.close()
    }
  }
}

这是封装的一个websocket服务,他会返回一个对象,这个对象可以向我们的服务端广播更新的信息,我们只需要调用这个对象的send方法即可

再来看看hmr的代码

import { ServerContext } from './../index';
import picocolors from "picocolors";
import { getShortName } from "./utils";
export function bindingHMREvents(serverContext: ServerContext) {
  const { watcher, ws, root } = serverContext;
  watcher.on("change", async (file) => {
    console.log(`✨${picocolors.blue("[hmr]")} ${picocolors.green(file)} changed`);
    const { moduleGraph } = serverContext;
    await moduleGraph.invalidateModule(file);
    let arr = moduleGraph.getboundaries("/" + getShortName(file, root)).map(item => ({
      type: "js-update",
      timeStamp: Date.now(),
      path: item,
      acceptedPath: item
    }))
    ws.send({
      type: "update",
      updates: arr,
    })
  })
}

这里是监听文件变化,如果文件发送变化,则使用ws发动更新信息,通知客户端,这里有个边界寻找方法moduleGraph.getboundaries("/" + getShortName(file, root))这个方法会帮助我们找到所有的依赖此文件的父级。

这是一个虚拟模块,它会将src\node\plugins\hmr\client.ts中的代码注入到客户端中

//src\node\plugins\clientInject.ts
import { CLIENT_PUBLIC_PATH, HMR_PORT } from "../contants";
import { Plugin } from "./plugin";
import fs from "fs-extra";
import esbuild from "esbuild"
import path from "path";
import { ServerContext } from "../../index";
function clientInjectPlugin(): Plugin {
  let serverContext: ServerContext;
  return {
    name: "m-vite:client-inject",
    configureServer(s) {
      serverContext = s
    },
    resolveId(id) {
      if (id === CLIENT_PUBLIC_PATH) {
        return { id }
      }
      return null
    },
    async load(id) {
      if (id === CLIENT_PUBLIC_PATH) {

        let realpath = path.join(import.meta.dirname, "hmr", "client.ts");
        let { code } = await esbuild.transform(await fs.readFile(realpath, "utf-8"), {
          loader: "ts",
          target: "esnext"
        })
        code = code.replace("__HMR_PORT__", JSON.stringify(HMR_PORT));
        return code
      }

    },
    transformIndexHtml(raw) {
      return raw.replace(/(<head[^>]*>)/i, `$1<script type="module" src="${CLIENT_PUBLIC_PATH}"></script>`)
    }
  }
}
export { clientInjectPlugin }

与此同时,src\node\plugins\importAnalysis.ts中也要修改一下code,方便客户端使用。

      if (!id.includes("node_modules")) {
        let res = Array.from(getboundaries(curmod, new Set()));
        ms.prepend(`import { createHotContext as __vite__createHotContext } from "${CLIENT_PUBLIC_PATH}";` + `import.meta.hot = __vite__createHotContext(${JSON.stringify(cleanUrl(curmod.url))},${JSON.stringify(res)});`)
      }


这里我们建立websocket的链接,一旦我们接受到服务器的信息,就会执行handleMessage方法;

const socket = new WebSocket(`ws://localhost:__HMR_PORT__`, "vite-hmr");
let boundaries: any;
socket.addEventListener("message", async ({ data }) => {

  handleMessage(JSON.parse(data)).catch(console.error)
});
async function handleMessage(payload: any) {
  switch (payload.type) {
    case "connection":
      console.log(`[vite] connected.`);
      setInterval(() => socket.send("ping"), 1000);
      break;
    case "update":
      console.log(payload.updates)
      payload.updates.forEach((update: any) => {
        if (update.type === "js-update") {
          fetchUpdate(update)
        }
      })
      break;
  }
}

我们会使用心跳检测,去判断是否能链接上服务器,如果能链接上,则会发送一个心跳包 如果是服务端发送的更新请求,则会调用fetchUpdate方法对依赖进行重新拉取

async function fetchUpdate({ path, timeStamp }: any) {
  // console.log(path)
  const mod = hotModulesMap.get(path);
  console.log(boundaries, "mod---------------------", path)
  if (!mod) return;
  const moduleMap = new Map();
  const modulesToUpdate = new Set<string>();
  modulesToUpdate.add(path);
  // boundaries.forEach((item:string) => {
  //   modulesToUpdate.add(item);
  // })
  await Promise.all(Array.from(modulesToUpdate).map(async (dep) => {
    const [path, query] = dep.split("?");

    try {
      const newMod = await import(path + `?t=${timeStamp}${query ? `&${query}` : ""}`);
      moduleMap.set(dep, newMod)
    } catch (e) {
      console.error(e)
    }
  }))
  return () => {
    for (const { deps, fn } of mod.callbacks) {
      fn(deps.map(dep => moduleMap.get(dep)));
      console.log(`[vite] hot updated: ${path}`);
    }
  }
}

这里我们就将更新过的mod信息,通过fetchUpdate方法,重新加载模块,然后执行回调函数,实现热更新。

我们css的热更新也是这个方式,只不过我们是在更新的时候重新读取css文件,将文件写入style标签中,每一次更新 则是委托客户端重新引入新的csscontent文件;

我们的热更新也就初步实现了

但我们还有以下几个问题

  1. 写入import.meta.hot上面的方法,对齐热更新依赖图谱
  2. 这个热更新是摧毁式的热更新,我们该怎么处理

我在这里抛砖引玉,这也就是剩下的一部分工作了,我先提几个点,日后有时间会续写上去的。

5.1、探讨如何写入import.meta.hot方法和其作用
      if (!id.includes("node_modules")) {
        let res = Array.from(getboundaries(curmod, new Set()));
        ms.prepend(`import { createHotContext as __vite__createHotContext } from "${CLIENT_PUBLIC_PATH}";` + `import.meta.hot = __vite__createHotContext(${JSON.stringify(cleanUrl(curmod.url))},${JSON.stringify(res)});`)
      }

这里个方法主要目的是接受服务端的mod信息,构建热更新环境

export const createHotContext = (ownerPath: string, arr: any) => {

  boundaries = arr;
  const mod: HotModule = hotModulesMap.get(ownerPath) || {
    id: ownerPath,
    callbacks: []
  };
  hotModulesMap.set(ownerPath, mod)

  if (mod) {
    mod.callbacks = []
  }
  function acceptDeps(deps: string[], callback: any) {

    const mod: HotModule = hotModulesMap.get(ownerPath) || {
      id: ownerPath,
      callbacks: []
    };
    mod.callbacks.push({ deps, fn: callback });
    hotModulesMap.set(ownerPath, mod)
  }
  return {
    accept(deps: any, callback?: any) {

      if (typeof deps === "function" || !deps) {
        //@ts-ignore
        acceptDeps([ownerPath], ([mod]) => deps && deps(mod))
      }
    },
    prune(cb: (data: any) => void) {
      pruneMap.set(ownerPath, cb)
    }
  }
}

我们这里会对依赖进行收集,但这个代码并不能使用来实现毫秒级的保留状态的热更新,我们在acceptDeps方法中,并没有实现提取该依赖更新的deps,我们提取deps后,需要重新的根据deps引入新的代码块,来实现重新渲染,之前使用的方法则是不管其他,我们将所有相关的文件重新拉去,这里则是需要进行细化的地方,比如寻找热更新的边界,如何确定热更新最大影响的区域。确定后我们需要重新的对deps进行拉去,需要由一定的vue-hmrreact-hmr插件的支持,我们要将发送过来的code文本,进行重新的渲染;

5.2、探讨如何寻找热更新边界

第二个难点是如何去进行热更新边界的判断,在vite源码中,是通过propagateUpdate这个方法递归往上查找的,如果往上查找到了热更新边界则会退出,如果没查到则会一直查下去,直到没有为止,这个代码改写难度太大了,希望各位有更好的方法融入

// 热更新边界收集
function propagateUpdate(
  node: ModuleNode,
  boundaries: Set<{
    boundary: ModuleNode
    acceptedVia: ModuleNode
  }>,
  currentChain: ModuleNode[] = [node]
): boolean {
   // 接受自身模块更新
   if (node.isSelfAccepting) {
    boundaries.add({
      boundary: node,
      acceptedVia: node
    })
    return false
  }
  // 入口模块
  if (!node.importers.size) {
    return true
  }
  // 遍历引用方
  for (const importer of node.importers) {
    const subChain = currentChain.concat(importer)
    // 如果某个引用方模块接受了当前模块的更新
    // 那么将这个引用方模块作为热更新的边界
    if (importer.acceptedHmrDeps.has(node)) {
      boundaries.add({
        boundary: importer,
        acceptedVia: node
      })
      continue
    }

    if (currentChain.includes(importer)) {
      // 出现循环依赖,需要强制刷新页面
      return true
    }
    // 递归向更上层的引用方寻找热更新边界
    if (propagateUpdate(importer, boundaries, subChain)) {
      return true
    }
  }
  return false
}