302023.1

基于构建的无侵入类方法实现:用webpack loader来改写原始类

在项目中我们需要针对不同的平台(Web和Native)做不同实现,在抽象中实现大部分功能,把细节实现留到具体平台的代码中,但是一直没有找到好的方法,如果直接在入口文件中引入实现文件,就会导致原始代码被打包的启动文件中,文件体积变大,但如果使用import()又无法确保每次代码加载都是ok的,总不可能自己实现顶层的await import,另外这些实现常常是侵入式的,需要对原始类进行方法覆盖。今天想到一种基于构建工具的无侵入式实现,这里无侵入是指把实现作为旁路代码,而不是主体入口代码。这种旁路代码有点像依赖注入,但是是从构建工具的角度来做,实际上也很简单,通过webpack的loader,把原始代码进行改写,把实现代码合并到原始代码中去。

/* eslint-disable @typescript-eslint/no-require-imports */

const fs = require('fs');
const path = require('path');

module.exports = function (contents) {
  const { resourcePath } = this;
  const options = this.getOptions();
  const { abstractDir, implementDir } = options;
  if (resourcePath.indexOf(abstractDir) === 0) {
    const implementFilePath = path.resolve(implementDir, resourcePath.replace(abstractDir, '.'));
    if (fs.existsSync(implementFilePath)) {
      const implementContents = fs.readFileSync(implementFilePath).toString();
      const newContents = composeFileContents(contents, implementContents);
      return newContents;
    }
  }
  return contents;
};

function composeFileContents(sourceContents, implementContents) {
  const sourceLines = sourceContents.split('\n');
  const implementLines = implementContents.split('\n');
  implementLines.shift(); // 去掉第一行,第一行是对原始文件(要被实现的文件)的引入

  const { imports: sourceImports, codes: sourceCodes } = splitCodes(sourceLines);
  const { imports: implementImports, codes: implementCodes } = splitCodes(implementLines);

  const { contents: imports } = composeImports(sourceImports, implementImports);
  const { contents: codes } = composeCodes(sourceCodes, implementCodes);

  return `${imports}\n${codes}`;
}

function splitCodes(lines) {
  const imports = [];
  const codes = [];

  let reach = false;
  let incomment = false;

  lines.forEach((line) => {
    const text = line.trim();
    const push = () => {
      if (reach) {
        codes.push(line);
      } else {
        imports.push(line);
      }
    };

    // 忽略注释
    if (text.indexOf('/*') === 0) {
      incomment = true;
      push();
      return;
    }
    if (text.substring(text.length - 2) === '*/') {
      incomment = false;
      push();
      return;
    }
    if (incomment) {
      push();
      return;
    }
    if (text.indexOf('//') === 0) {
      push();
      return;
    }

    if (text.indexOf('import ') === 0) {
      imports.push(line);
    } else {
      codes.push(line);
      reach = true;
    }
  });

  return { imports, codes };
}

function composeImports(sourceImports, implementImports) {
  // TODO: 需要考虑如果import了相同的变量名的问题
  const importMapping = {};
  const importVars = {};

  sourceImports.forEach((line) => {
    if (line.indexOf('import ') !== 0) {
      return;
    }
    const { vars, src, def } = parseImport(line);
    importMapping[src] = { vars, src, def };
    if (vars) {
      vars.forEach((v) => {
        importVars[v] = true;
      });
    }
    if (def) {
      importVars[def] = true;
    }
  });

  // TODO: 暂时未考虑default的冲突问题
  implementImports.forEach((line) => {
    const { vars, src, def } = parseImport(line);
    if (importMapping[src]) {
      const importVars = importMapping[src].vars;
      // TODO: 暂时未考虑import as后的变量名冲突问题
      if (vars && importVars) {
        importVars.push(...vars);
      }
    } else {
      importMapping[src] = { vars, src, def };
    }
  });

  // 先处理原始的
  const results = [];
  sourceImports.forEach((line) => {
    if (line.indexOf('import ') !== 0) {
      results.push(line);
      return;
    }
    const { src } = parseImport(line);
    const importText = createImport(importMapping[src]);
    results.push(importText);
    delete importMapping[src];
  });

  // 再处理多出来的
  const srcs = Object.keys(importMapping);
  srcs.forEach((src) => {
    const importText = createImport(importMapping[src]);
    results.push(importText);
  });

  return { contents: results.join('\n') };
}

function composeCodes(sourceCodes, implementCodes) {
  const source = sourceCodes.join('\n');
  const imports = `(function() {
    ${implementCodes.join('\n')}
  } ())`;
  return { contents: `${source}\n${imports}` };
}

function parseImport(importLine) {
  const [, exp, src] = importLine.match(/import ([\w\W]+) from ['"](.*?)['"]/m);

  const parseVar = (txt) => {
    // if (txt.indexOf(' as ') > -1) {
    //   const [, v] = item.split(' as ');
    //   return v.trim();
    // }
    const t = txt.trim();
    return t;
  };
  const parseVars = (txt) => {
    const t = txt.substring(1, txt.length - 1);
    const items = t.split(',');
    const list = items.map(parseVar);
    return list;
  };

  const txt = exp.trim();
  if (/^\{.*?\}$/.test(txt)) {
    const vars = parseVars(txt);
    return { vars, src };
  }
  if (/^\w+,.*?\}$/.test(txt)) {
    const [d, i] = txt.split(',').map((item) => item.trim());
    const vars = parseVars(i);
    const def = parseVar(d);
    return { src, vars, def };
  }
  const def = parseVar(txt);
  return { src, def };
}

function createImport(mapItem) {
  const { vars, src, def } = mapItem;
  if (def && vars) {
    return `import ${def}, { ${vars.join(', ')} } from '${src}';`;
  }
  if (def) {
    return `import ${def} from '${src}';`;
  }
  if (vars) {
    return `import { ${vars.join(', ')} } from '${src}';`;
  }
  return '';
}

这里的实现方式比较暴力,就是通过替换的形式,把实现代码合并进原始代码。这些代码一般被放在一个叫 @implements 的目录中,没有任何其他文件引用它们,构建工具根据其给定的路径进行匹配,如果路径匹配上了,就执行合并逻辑,因此称它们为旁路代码。不过,如果原始文件中缺少这部分实现,则无法运行。

虽然这种方式割裂了代码本身的逻辑,无法通过编辑器的源文件链接找到,但是,这种方式借助构建工具,使得代码层面更加清晰,最终的产物更加合理(主体代码中不存在与之无关的代码)。

19:50:01 已有0条回复
222023.1

如何终止fetch发出的请求?

我们知道xhr可以调用abort来终止请求,但是fetch如何终止却鲜有人知,其实非常简单,只是我们不够了解,即使用AbortController,具体如下:

const controller = new AbortController();
const signal = controller.signal;

const url = "video.mp4";
const downloadBtn = document.querySelector(".download");
const abortBtn = document.querySelector(".abort");

downloadBtn.addEventListener("click", fetchVideo);

abortBtn.addEventListener("click", () => {
  controller.abort();
  console.log("Download aborted");
});

function fetchVideo() {
  fetch(url, { signal })
    .then((response) => {
      console.log("Download complete", response);
    })
    .catch((err) => {
      console.error(`Download error: ${err.message}`);
    });
}

即在fetch时传入signal作为信令,当调用controller.abort()时,该信令就会发出终止请求的信号,fetch发出的请求就会被终止,其Promise会抛出一个AbortError。

21:47:53 已有0条回复
032023.1

前端代码中,哪些可以抽象,哪些不可以?

抽象的目的绝对不是复用,而是为了清晰的设计。从某种意义上讲,抽象分为两种,一种是绝对抽象,只有神态,没有实体,另一种是轮廓抽象,或者半抽象,有了基本的架子,留下空间去具体化。前一种我们常称之为接口interface,后一种我们常称之为抽象类abstract class。对于前端代码而言,在传统前端开发模式中,不存在这两种中的任何一种,对于abstract class则可能存在一些雏形,例如:

class Some {
stay() {
throw new Error('stay should be overrided')
}
}

此类处理虽然可行,但是在最终代码中会多出来许多没有用的代码,增加代码量。只有在我们引入ts之后,才有了真正的抽象代码。例如:

abstract class Some {
abstract stay(): void
}

这段代码定义了一个抽象类,它不能被new实例化,只能被extends扩展。扩展时,带有abstract前缀的成员必须被实现,否则编译阶段会报错,而在最终生成的代码中,不存在这些abstract成员,这样就可以使代码量最小化。

在具体业务中,哪些内容可以抽象,哪些不可以呢?

其实对于前端而言,基于interface的抽象(业务层面)几乎没有,我们很少会去写一个用于描述业务的interface,而且这完全没有必要。我们大部分情况下使用abstract class进行抽象,甚至在大部分情况下,不需要abstract,直接使用class进行抽象。这里抽象脱离了技术层面的抽象,而是对业务进行抽象,同时由于大部分前端场景中,同一业务是固定不变的,因此,这类抽象可以被具体实现。例如对于同一对象实体,我们直接对其进行建模。再例如,我们直接对数据请求进行建模。对于数据的操作,我们进行建模。总之,你会发现,抛开界面和交互的一切,都是可以进行建模处理的,这个部分全部可以抽象出来,在代码中形成一块封闭的可扩展的代码块,需要时被取出来使用,不需要时不import,对当前毫无影响。

最麻烦的是界面和交互的抽象。

先看下交互的抽象,由于交互动作往往会引起界面的变化,所以我们在对交互进行建模时,就必须预留下可产生界面变化的abstract成员,从而可以使界面产生变化。例如:

abstract class SomeView extends View {
@inject(SomeController)
controller: SomeController

abstract confirm(message: string, onOk: Function): void

deleteItem(id) {
this.confirm('确定删除吗?', () => {
// 执行删除
})
}
}

上面代码中,我们预留了一个confirm方法,对于具体的某个view而言,必须扩展这个confirm方法来,为什么要留呢?因为confirm往往需要弹出一个对话框,同时,这个对话框一定是一个中间态,用户点击它的按钮之后,一定还会有后续界面变化。这种界面的流动过程,无法通过简单的处理来实现。当然,如果你想具体化,还可以借助react的state,例如:

class SomeView extends View {
state = {
showConfirm: false,
deleteItemId: null,
}

deleteItem(id) {
this.setState({ showConfirm: true, deleteItemId: id })
}

handleDeleteItem() {
// 执行删除动作
}
}

通过以上方式确实可以做到提供完整的动作,但是这就意味着必须按照react的状态管理模式进行编程,而且对于使用方来说,一个动作的方法太多了,在实现时,不仅要调用deleteItem还要再调用handleDeleteItem。不过,从另外一个角度看,这似乎又是正确的一种做法。

最后看下界面的抽象。这个时最难的,因为不同端端界面呈现是不一样的。比如PC和APP上。但是我们也不是不能做,其前提是开发者,或者项目的架构师,在前期规划了非常细腻的业务组件,我们所有的业务开发,基于已有的业务组件进行,我们写代码,更想写配置或DSL,比如下面:

<Page>
<ProjectBasicInfo />
<ProjectMembers />
<ProjectDeals />
<Tabs>
<CompanyInfo />
<FinancingInfo />
</Tabs>
</Page>

这样一段代码,更像是一个页面的结构描述,至于每一个部分都具体展示什么内容,怎么展示,界面交互怎样,PC和APP上的差异,全靠业务组件内自己去实现,通过这种方式来进行界面的抽象,可以最大程度的抹平不同端的差异,但是对团队和架构师的要求会比较高,当然收益也是显而易见的,就是效率很高。

11:42:09 已有0条回复