浅学前端与数据结构算法 | 青训营笔记

113 阅读8分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 18 天

一、本堂课重点内容:

本堂课的知识要点有哪些?

  • 引言
  • 实战案例分享
    • 遍历所有文件
    • AST解析器
    • 模块打包器(Bundler)
    • 缓存淘汰处理
    • 相似命令提示
  • 小结

二、详细知识点介绍:

引言

数据结构与算法是学习计算机科学与技术的同学们的基础课程,同样也是程序员求职必备的准备项目。在学校里老师往往照本宣科的讲解原理,很少能够切身体会到数据结构其中的美妙以及实际应用;前端程序员的日常工作又是切图业务的日常,较少的去应用数据结构与算法解决实际问题。但本文不是讲述数据结构与算法原理的,而是通过站在前端程序员的角度讲述鲜活的工程应用实例来体会数据结构与算法的美妙。

实战案例分享

遍历所有文件

问题:用Node.js扫描指定目录下的所有文件

const fs = require('fs');

function scanDirectory(directoryPath){
    let allFiles = [];
   
   function readDir(path){
       let files = fs.readdirSync(path);
       files.forEach((file) => {
           let fullPath = path + "/" + file;
           let stats = fs.statSync(fullPath);
           
           if (stats.isDirectory()) {
               readDir(fullPath);
           } else {
               allFiles.push(fullPath);
           }
       });
   }
   
   readDir(directoryPath);
   return allFiles;
}

这是一个基础的递归程序代码,而递归是许多算法的重要实现基础,同时也是新手入门编程的门槛。在这里通过递归实现了DFS(深度优先遍历)的算法,在实际的工程实践中,我们经常见到如下类似场景:

  • 页面侧边栏:

image.png

  • 文件树

image.png

AST解析器

AST即抽象语法树,通过AST解析器,我们可以将代码解析为一颗对象树的结构,每个语法都对应着树里面的某些节点。这样可以便于开发者进行语法分析和转换,无论是HTML、CSS、Javascript,其背后的解析语法都是相似的。

对于AST解析器来说,最重要的是两步:

  1. tokenize(词法分析)
  2. parse(语法分析)

如下代码:

let foo = function(){}

首先将代码分割为一个又一个词法单元,便于后面的语法分析,代码分割为如下token数组示例:

['let','foo','=','function','(',')','{','}']

词法分析器(Tokenizer):本质上是对代码字符串进行逐个字符的扫描,然后按照一定语法规则进行分组。其中关键步骤:

  • 确定语法规则,包含语言内置的关键词、单字符、分隔符等;
  • 逐个代码字符扫描,根据语法规则进行token分组。

语法解析阶段中,逐个解析token,构造出一个完整的对象树结构,伪代码示例如下:

//以此行代码 let foo = function(){} 为例子
function parse(){
    //处理 let 这个 token 
    consume('let');
    //通过 let 后面的变量名 foo 构造 Identifier 节点
    const id = this.parseIdentifier();
    //处理 = 这个 token 
    consume('=');
    //解析函数表达式,生成相应节点
    const init = this._parseFunctionExpression();
    const declarator:VariableDeclarator ={
        type:  NodeType.VariableDeclarator,
        id,
        init,
    };
    return declarator
}

一百来行 JS 写个玩具 HTML Parser - 知乎 (zhihu.com)

简易Babel Parser实现 BY sanyuan0704/juejin-book-vite · GitHub

模块打包器(Bundler)

Bundler是前端工程化中十分核心的一环,目前社区知名的Bundler包括Webpack、Rollup、Esbuild、Turbopack等。Bundler自身工程复杂度相当高,这里仅以部分环节为例介绍其背后的基本原理

  • 依赖图建立
  • 循环依赖分析

首先通过AST解析器从入口开始分析模块的依赖关系,代码示例如下:

//main.js
import { a } from './utils';

console.log (a);

//utils.js
export const a = 1;

export const b = 2;

Bundler在内部初始化main.js的模块对象,解析AST,分析import节点,通过import节点找到其所引用的模块,接着对这些模块进行初始化,以此类推,最后构造出完整的模块依赖关系

Bundler也会进行循环依赖的检查,例如出现以下情况:

image.png

其实这是一个有向图是否成环的问题。可以从入口节点开始,对整张图进行后序遍历(也称 拓扑排序

image.png

其中A依赖B和C,B和C依赖D,D依赖E,那么拓扑排序后即EDBCA。其核心的检查循环依赖的代码示例如下:

//拓扑排序模块数组
const orderedModules:Module[] = [];
//记录已经分析过的模块表
const analyseModule:Record<string,boolean> = {};
//记录模块的父模块 id
const parent: Record<string,string> = {};

function analyseModule(module:Module) {
    if (analyseModule[module.id]) {
        return;
    }
    for (const dependency of module.dependencyModules){
    //检测循环依赖
    //1.不是模块入口
        if (parent[dependency.id]){
        //2.依赖模块还没有分析结束
            if (!analyseModule[dependency.id]){
                cyclePathList.push(getCyclePath(dependency.id,module.id));
            }
            continue;
        }
        parent[dependency.id] = module.id;
        analyseModule(dependency);
    }
    analyseModule[module.id] = true;
    orderedModules.push(module);
}


//用来回溯,用于定位循环依赖
function getCyclePath(id:string, parentID:string): string[]{
    const paths = [id];
    let currentID = parentID;
    while (currentID !== id){
        paths.push(currentID);
        //向前回溯
        currentID = parent[currentID];
    }
    paths.push(paths[0]);
    return paths.reverse();
}

拓扑排序不仅在检查循环依赖时十分有效,在其他涉及到依赖顺序关系的情境下都十分实用。例如Monorepo工具的命令调度,假设现有三个子项目A、B、C,B是A的依赖,C是B的依赖,那么Monorepo就会在A构建之前将B和C提前构建,即先构建依赖,后构建自身,这一步骤就需要使用拓扑排序算法进行依赖排序。

简易Bundler实现 BY sanyuan0704/juejin-book-vite · GitHub

tree shaking问题排查指南 - 知乎 (zhihu.com)

缓存淘汰处理

日常开发中会用到缓存来提升性能。

  • SSR缓存,服务端将组件渲染为字符串之后缓存HTML,后续访问可直接读取内存中的缓存,跳过CPU密集型操作。
  • Vue的KeepAlive组件缓存了内部组件的实例,避免再次渲染时重新创建组件,请求数据的性能开销。
  • Webpack对loader的结果进行缓存,二次启动可以跳过loader的处理,大幅提升构建效率。

涉及到缓存,需要考虑缓存淘汰问题,例如SSR的结果缓存到内存里,内存是有限的,过多的缓存内容又会影响服务稳定性。

一般情况下,设置一个缓存的阈值,缓存大小超过阈值时选择性删除某些缓存节点。

常用算法是LSU(Least Recently Used Algorithm)(最近最少使用原则),即把最近使用频率低的节点进行删除,保留使用频率高的内容。

lru-cache - npm (npmjs.com)

相似命令提示

进行命令行工具开发时,为了提升用户体验,可以在用户输入错误的时候对其进行提示,告诉用户最接近的命令,示例如下:

image.png

这个功能的实现基于最小编辑距离算法。

编辑距离(Edit Distance),又称Levenshtein距离,是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。一般来说,编辑距离越小,两个串的相似度越大。

最小编辑距离模板:

int dp[1005][1005];     /*dp[i][j]表示表示A串从第0个字符开始到第i个字符和B串从第0个
字符开始到第j个字符,这两个字串的编辑距离。字符串的下标从1开始。*/
char a[1005],b[1005];   //a,b字符串从下标1开始
 
int EditDis()
{
    int len1 = strlen(a+1);
    int len2 = strlen(b+1);
    //初始化
    for(int i=1;i<=len1;i++)
        for(int j=1;j<=len2;j++)
            dp[i][j] = INF;
    for(int i=1;i<=len1;i++)
        dp[i][0] = i;
    for(int j=1;j<=len2;j++)
        dp[0][j] = j;
    for(int i=1;i<=len1;i++)
    {
        for(int j=1;j<=len2;j++)
        {
            int flag;
            if(a[i]==b[j])
                flag=0;
            else
                flag=1;
            dp[i][j]=min(dp[i-1][j]+1,min(dp[i][j-1]+1,dp[i-1][j-1]+flag));
            //dp[i-1][j]+1表示删掉字符串a最后一个字符a[i]
            //dp[i][j-1]+1表示给字符串添加b最后一个字符
            //dp[i-1][j-1]+flag表示改变,相同则不需操作次数,不同则需要,用flag记录
        }
    }
    return dp[len1][len2];
}

GitHub - yefim/autocorrect: Autocorrect in JS

GitHub - sindresorhus/leven: Measure the difference between two strings with the fastest JS implementation of the Levenshtein distance algorithm

小结

以上实践中,有着如DFS、有向图成环等经典算法,也有一些小型的算法设计解决实际问题。编程的本质是采取恰当的数据结构和算法解决一些特殊问题。编程的学习之路从上学到工作都只是刚刚开始,任重而道远。

在前端开发中,绝不是只做做前端页面就好,实现UI的过程,包括文件树、侧边栏的实现都需要数据结构的知识进行解决,其次还有更深入的工程化的工具链、可视化、跨端等细分领域,每个领域有着不同的问题,需要采取不同的算法进行实现。一旦踏上编程的学习之路,就意味着需要持之以恒的一直学习下去,才能保证与时俱进,才能成长进步。仅这一点,就太美妙了。

三、引用参考: