解析effet.js 核心实现原理
在当今的前端开发领域,Vue、React、Angular 等框架几乎成了主流选择。然而,这些框架背后究竟是如何工作的?如果我们从头开始构建一个属于自己的前端框架,会是怎样的体验?本篇博客将从零开始,逐步揭示一个前端框架的基本结构和底层实现原理,逐步探索每一个核心技术背后的逻辑。希望通过这篇文章,能够帮助你更加深入理解前端框架的工作机制,并为你提供自我挑战的动力。
创建文件夹
我们叫做effet
选择打包工具
目前比较常见的打包工具如:Webpack ,Rollup,Vite,Snowpack,esbuild,Parcel
Vite:以速度快著称,基于原生 ES 模块开发,不需要进行繁重的打包,开发阶段的 HMR(热模块替换)速度更快。Vite 更适合现代 JavaScript 框架的开发,特别是 Vue 和 React 项目。
Rollup:专注于 JavaScript 库和框架的打包,生成较小的文件体积,特别适合构建轻量的前端库或框架。Rollup 支持 Tree Shaking 和多种输出格式(如 ES 模块和 CommonJS),有助于优化包的体积。
Parcel:零配置的打包工具,支持代码分割、热更新、Tree Shaking 等功能。Parcel 会自动处理许多配置需求,适合快速启动和较简单的打包需求。
esbuild:超快的 JavaScript 和 TypeScript 打包工具,基于 Go 开发,处理大项目时速度非常快。虽然 esbuild 的插件生态相对较小,但在大型项目中用作辅助构建工具非常出色。
Snowpack:类似 Vite,不进行捆绑,基于原生 ES 模块加载,适合现代 Web 应用的开发阶段。Snowpack 支持 HMR,允许只更新修改的模块,极大地加快开发速度。
我们选择最常见的
Webpack 进行打包
创建必要的配置
首先创建一个src文件夹,后面用于编写核心代码
创建 package.json
{
"name": "face-effet",
"version": "1.3.6",
"main": "index.js",
"description": "effet.js 是一个轻量级的人脸样式框架,专注于为网页带来生动的面部动画效果。通过简单的API,开发者可以轻松实现眨眼、张嘴、摇头等动态表情,使用户界面更加互动和生动。effet.js 适用于需要增强用户体验的各种应用场景,特别是在前端项目中集成复杂的人脸动态效果。",
"private": false,
"keywords": ["effet", "javascript", "framework", "ui", "animation","face-effet"],
"author": "typsusan",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/typsusan/effet.git"
},
"repository-gitee": {
"type": "git",
"url": "https://gitee.com/susantyp/effet.git"
},
"scripts": {
"build": "webpack"
},
"files": [
"effet","README.md","parameter.md","package.json","License"
],
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.9.1",
"postcss": "^8.4.41",
"postcss-loader": "^8.1.1",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.5.4",
"webpack": "^5.0.0",
"webpack-cli": "^4.0.0"
}
}
创建 tsconfig.json,因框架内部需要用到ts相关
{
"compilerOptions": {
"outDir": "./effet/",
"noImplicitAny": true,
"module": "es6",
"target": "es5",
"allowJs": true,
"lib": ["es6", "dom"],
"moduleResolution": "node",
"sourceMap": true,
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
}
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
打包配置
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
module.exports = {
entry: {
index: './src/index.js',
},
output: {
filename: 'effet.js',
path: path.resolve(__dirname, 'effet'),
library: 'effet',
libraryTarget: 'umd',
globalObject: 'this',
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
optimization: {
splitChunks: {
chunks: 'all',
name: 'common',
},
minimizer: [
`...`,
new CssMinimizerPlugin(), // 添加CSS压缩插件
],
},
experiments: {
asyncWebAssembly: true,
syncWebAssembly: true,
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
}
},
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
{
test: /\.css$/, // 处理 CSS 文件
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader'
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: 'effet.css',
}),
],
mode: 'production',
};
编写核心入口文件夹,以及文件
下面以effet.js做为示例
入口文件
import { faceElements } from "./core/dom/createFaceElements.js";
import { restart, start, close } from "./core/index";
import def from './core/defaultAssign/assign.js';
import { FACE_TYPE, FACE_SIZE } from "@/components/enums/Constant.ts";
import { cacheAllFiles } from "./core/db/db";
import './core/log/log'
// 引入样式文件
const requireStyles = require.context('./styles', true, /\.css$/);
requireStyles.keys().forEach(requireStyles);
// 初始化函数
export function init(obj) {
if (!obj?.el) {
throw new Error("Element not provided. Please pass a valid DOM element to initialize effet.");
}
// 初始化基础设置
def(obj, FACE_TYPE, FACE_SIZE);
faceElements.init(obj);
// 缓存完成后再启动
cacheAllFiles()
.then(() => {
start(obj);
})
.catch(error => {
console.error('Cache failed! Please check your network. The system is attempting to cache again for you.', error);
// 即使缓存失败,也尝试启动
start(obj);
});
}
export function cache() {
cacheAllFiles().then(() => {
console.log('Cache completed');
}).catch(error => {
console.error('Cache failed! Please check your network.', error);
});
}
// 导出模块
export {
restart,
close,
FACE_TYPE,
FACE_SIZE
};
export default {
init,
close,
restart,
FACE_TYPE,
FACE_SIZE,
cache
};
index.js 用于初始化和控制某个面部特效(或交互)系统。具体来说,它负责初始化设置、样式引入、缓存资源文件等。以下是代码中各个部分的详细解释:
-
模块引入:
import { faceElements } from "./core/dom/createFaceElements.js";
: 引入faceElements
对象,可能包含创建或管理面部元素的方法。import { restart, start, close } from "./core/index";
: 引入控制系统的核心方法,包括restart
(重启)、start
(启动)和close
(关闭)。import def from './core/defaultAssign/assign.js';
: 引入默认分配设置方法def
,用于初始化基础配置。import { FACE_TYPE, FACE_SIZE } from "@/components/enums/Constant.ts";
: 引入两个常量FACE_TYPE
和FACE_SIZE
,代表面部类型和大小的设置。import { cacheAllFiles } from "./core/db/db";
: 引入cacheAllFiles
,用于缓存相关文件资源。import './core/log/log'
: 引入日志模块,为后续日志记录提供支持。
-
引入样式文件:
const requireStyles = require.context('./styles', true, /\.css$/); requireStyles.keys().forEach(requireStyles);
这部分代码动态地引入
./styles
文件夹中的所有 CSS 文件。require.context
是 Webpack 提供的一个方法,用于批量引入模块。此处遍历requireStyles
中的所有 CSS 文件路径并加载它们。 -
初始化函数
init
:export function init(obj) { if (!obj?.el) { throw new Error("Element not provided. Please pass a valid DOM element to initialize effect."); } def(obj, FACE_TYPE, FACE_SIZE); faceElements.init(obj); cacheAllFiles() .then(() => { start(obj); }) .catch(error => { console.error('Cache failed! Please check your network. The system is attempting to cache again for you.', error); start(obj); }); }
init(obj)
是初始化函数,接受一个对象obj
,其中应该包含一个 DOM 元素el
。- 检查
obj.el
是否存在,如果不存在则抛出错误。 - 调用
def(obj, FACE_TYPE, FACE_SIZE);
为对象obj
设置默认值。 - 使用
faceElements.init(obj);
初始化面部元素。 - 调用
cacheAllFiles
缓存文件资源。在缓存完成后调用start(obj)
启动系统,如果缓存失败,则输出错误信息并仍尝试启动系统。
-
缓存函数
cache
:export function cache() { cacheAllFiles().then(() => { console.log('Cache completed'); }).catch(error => { console.error('Cache failed! Please check your network.', error); }); }
该函数手动触发缓存文件。缓存成功后输出“Cache completed”,失败则输出错误信息。
-
导出模块:
export { restart, close, FACE_TYPE, FACE_SIZE }; export default { init, close, restart, FACE_TYPE, FACE_SIZE, cache };
这里分别导出多个方法和变量。
export
导出指定模块项;export default
导出一个默认对象,包含init
、close
、restart
、FACE_TYPE
、FACE_SIZE
和cache
,以便模块在其他文件中被引入和使用。
核心动作基础算法
动作算法入口,可动态选择模块
/**
* ‘人脸动作’ 集合入口
* 'Face Action' collection entry
*/
import faceColor from "@/styles/faceColor";
import isEmptyFunctionUtil from "../../util/isEmptyFunctionUtil";
// 使用 require.context 动态加载模块
const actionModules = require.context('./', true, /\.js$/);
export default (appData, results, currentObj, callBackResult, stopRecording, startRecording) => {
appData.canvasCtx.save();
appData.canvasCtx.clearRect(0, 0, appData.canvasElement.width, appData.canvasElement.height);
appData.canvasCtx.drawImage(results.image, 0, 0, appData.canvasElement.width, appData.canvasElement.height);
if (results.multiFaceLandmarks && results.multiFaceLandmarks.length > 0) {
if (!appData.predictionState) {
appData.predictionState = true;
startRecording();
}
if (currentObj.action){
if (typeof currentObj.action === 'function'){
faceColor(appData.canvasCtx, results.multiFaceLandmarks, currentObj);
isEmptyFunctionUtil(currentObj.action,'action')
currentObj.action(appData,results,currentObj,callBackResult, stopRecording, startRecording)
}else {
throw Error("'action' is not a valid function")
}
}else {
// 动态加载模块
const actionModule = actionModules(`./${currentObj.type}/index.js`);
if (actionModule) {
const actionFunction = actionModule.default;
actionFunction(appData, results, currentObj, callBackResult, stopRecording,startRecording);
} else {
console.error(`无法找到模块:${currentObj.type}`);
}
}
} else {
callBackResult(currentObj, '未检测到人脸...', -2);
appData.predictionState = false;
appData.lastNoseX = null;
appData.noseXChanges = [];
if (appData.mediaRecorder && appData.mediaRecorder.state !== "inactive") {
appData.mediaRecorder.stop();
appData.mediaRecorder = null;
}
}
appData.canvasCtx.restore();
}
代码解析
-
模块导入:
import faceColor from "@/styles/faceColor"; import isEmptyFunctionUtil from "../../util/isEmptyFunctionUtil";
faceColor
是一个用于设置或渲染人脸颜色的函数或模块。isEmptyFunctionUtil
是一个实用函数,用于检查某个函数是否为空或无效。
-
动态加载模块:
const actionModules = require.context('./', true, /\.js$/);
require.context
是 Webpack 提供的功能,用于动态加载文件夹中的模块。这里加载当前目录(./
)中符合.js
格式的文件,用于动态调用动作模块。
-
默认导出函数:
export default (appData, results, currentObj, callBackResult, stopRecording, startRecording) => {
- 这是一个匿名函数,接收多个参数。
appData
:包含应用状态和画布上下文信息。results
:包含人脸检测的结果(如人脸 landmarks)。currentObj
:当前的动作对象,包含action
函数和type
等信息。callBackResult
:回调函数,用于处理无检测到人脸等情况。stopRecording
和startRecording
:控制录制状态的函数。
- 这是一个匿名函数,接收多个参数。
-
保存画布上下文并清空画布:
appData.canvasCtx.save(); appData.canvasCtx.clearRect(0, 0, appData.canvasElement.width, appData.canvasElement.height);
save()
:保存画布当前状态,便于稍后恢复。clearRect
:清空画布内容,准备绘制新的人脸图像。
-
绘制人脸图像:
appData.canvasCtx.drawImage(results.image, 0, 0, appData.canvasElement.width, appData.canvasElement.height);
- 将人脸图像(来自
results.image
)绘制到画布上,覆盖整个画布区域。
- 将人脸图像(来自
-
检测人脸:
if (results.multiFaceLandmarks && results.multiFaceLandmarks.length > 0) {
- 检查
results.multiFaceLandmarks
,判断是否检测到人脸。 - 如果检测到人脸,则进一步处理,否则执行“未检测到人脸”的逻辑。
- 检查
-
启动录制:
if (!appData.predictionState) { appData.predictionState = true; startRecording(); }
- 如果
predictionState
为false
(表明录制未启动),则将其设为true
并调用startRecording
开始录制。
- 如果
-
执行动作函数:
if (currentObj.action) { if (typeof currentObj.action === 'function') { faceColor(appData.canvasCtx, results.multiFaceLandmarks, currentObj); isEmptyFunctionUtil(currentObj.action, 'action'); currentObj.action(appData, results, currentObj, callBackResult, stopRecording, startRecording); } else { throw Error("'action' is not a valid function"); } }
-
如果
currentObj.action
存在且是函数,则:
- 调用
faceColor
对人脸进行着色或效果处理。 - 使用
isEmptyFunctionUtil
检查action
是否为空函数。 - 调用
currentObj.action
,传入所有必要参数,执行该动作。
- 调用
-
如果
action
不是有效的函数,抛出错误。
-
-
动态加载模块并执行默认动作:
const actionModule = actionModules(`./${currentObj.type}/index.js`); if (actionModule) { const actionFunction = actionModule.default; actionFunction(appData, results, currentObj, callBackResult, stopRecording, startRecording); } else { console.error(`无法找到模块:${currentObj.type}`); }
- 如果
currentObj.action
未定义,则尝试从文件夹./${currentObj.type}/
加载动作模块index.js
。 - 如果模块存在,调用模块的默认导出函数
actionFunction
,执行相应动作。 - 如果模块不存在,输出错误信息提示模块未找到。
- 如果
-
未检测到人脸的处理:
callBackResult(currentObj, '未检测到人脸...', -2); appData.predictionState = false; appData.lastNoseX = null; appData.noseXChanges = []; if (appData.mediaRecorder && appData.mediaRecorder.state !== "inactive") { appData.mediaRecorder.stop(); appData.mediaRecorder = null; }
- 如果未检测到人脸,调用
callBackResult
提示用户“未检测到人脸”。 - 将
predictionState
设为false
,重置记录的位置信息。 - 如果正在录制,通过
mediaRecorder.stop()
停止录制并重置mediaRecorder
。
- 如果未检测到人脸,调用
-
恢复画布上下文:
appData.canvasCtx.restore();
- 恢复画布状态到
save
时的状态,结束绘图过程。
- 恢复画布状态到
关键动作函数目录
其中包含
addFace 人脸添加
checkLogin 人脸登录
checkSleep 睡眠检测
clockIn 人脸打卡
人脸登录核心动作算法
import { distance } from "@/util/distanceUtils";
import faceColor from "@/styles/faceColor";
import { FaceManager } from "@/components/FaceManager.ts";
const NOSE_X_CHANGE_HISTORY_LENGTH = 10;
export default (appData, results, currentObj, callBackResult, stopRecording, startRecording) => {
const landmarks = results.multiFaceLandmarks[0];
faceColor(appData.canvasCtx, results.multiFaceLandmarks, currentObj);
// 获取面部关键点
const upperLipBottom = landmarks[13];
const lowerLipTop = landmarks[14];
const leftEyeTop = landmarks[159];
const leftEyeBottom = landmarks[145];
const rightEyeTop = landmarks[386];
const rightEyeBottom = landmarks[374];
const noseTip = landmarks[1]; // 鼻尖的标记点
// 计算动作状态
const mouthOpen = distance(upperLipBottom, lowerLipTop) > currentObj.threshold.lips;
const leftEyeOpen = distance(leftEyeTop, leftEyeBottom) > currentObj.threshold.eye;
const rightEyeOpen = distance(rightEyeTop, rightEyeBottom) > currentObj.threshold.eye;
const blinked = !(leftEyeOpen && rightEyeOpen);
let headShaken = false;
if (appData.lastNoseX !== null) {
let dx = Math.abs(noseTip.x - appData.lastNoseX);
appData.noseXChanges.push(dx);
if (appData.noseXChanges.length > NOSE_X_CHANGE_HISTORY_LENGTH) {
appData.noseXChanges.shift();
const maxChange = Math.max(...appData.noseXChanges);
if (maxChange > currentObj.threshold.headShake) {
headShaken = true;
}
}
}
appData.lastNoseX = noseTip.x;
// 初始化随机动作顺序
if (!appData.actionsSequence) {
appData.actionsSequence = ["blink", "mouth", "headShake"].sort(() => Math.random() - 0.5);
appData.currentActionIndex = 0;
appData.blinkDetected = false;
appData.mouthDetected = false;
appData.headShakeDetected = false;
}
const currentAction = appData.actionsSequence[appData.currentActionIndex];
switch (currentAction) {
case "blink":
if (!appData.blinkDetected) {
callBackResult(currentObj, "请眨眨眼");
FaceManager.getInstance().updateMessage(0, "请眨眨眼");
if (blinked) {
appData.blinkDetected = true;
callBackResult(currentObj, "眨眼检测通过");
appData.currentActionIndex++;
}
}
break;
case "mouth":
if (!appData.mouthDetected) {
callBackResult(currentObj, "请张张嘴");
FaceManager.getInstance().updateMessage(0, "请张张嘴");
if (mouthOpen) {
appData.mouthDetected = true;
callBackResult(currentObj, "张嘴检测通过");
appData.currentActionIndex++;
}
}
break;
case "headShake":
if (!appData.headShakeDetected) {
callBackResult(currentObj, "请左右摇头");
FaceManager.getInstance().updateMessage(0, "请左右摇头");
if (headShaken) {
appData.headShakeDetected = true;
callBackResult(currentObj, "摇头检测通过");
appData.currentActionIndex++;
}
}
break;
}
// 检查所有动作是否完成
if (appData.blinkDetected && appData.mouthDetected && appData.headShakeDetected) {
FaceManager.getInstance().updateMessage(0, "通过");
stopRecording(currentObj);
}
};
定义了一个人脸动作检测函数,主要用于识别眨眼、张嘴和摇头等动作,并依次完成指定的动作顺序。以下是对代码的详细解析:
import { distance } from "@/util/distanceUtils";
import faceColor from "@/styles/faceColor";
import { FaceManager } from "@/components/FaceManager.ts";
const NOSE_X_CHANGE_HISTORY_LENGTH = 10;
export default (appData, results, currentObj, callBackResult, stopRecording, startRecording) => {
const landmarks = results.multiFaceLandmarks[0];
faceColor(appData.canvasCtx, results.multiFaceLandmarks, currentObj);
// 获取面部关键点
const upperLipBottom = landmarks[13];
const lowerLipTop = landmarks[14];
const leftEyeTop = landmarks[159];
const leftEyeBottom = landmarks[145];
const rightEyeTop = landmarks[386];
const rightEyeBottom = landmarks[374];
const noseTip = landmarks[1]; // 鼻尖的标记点
- 模块导入:
distance
:用于计算两点之间的距离,帮助判断嘴唇和眼睛的张合状态。faceColor
:设置或渲染人脸的颜色效果。FaceManager
:用于显示或更新面部动作提示信息。
- 面部关键点识别:
- 通过
results.multiFaceLandmarks
获取人脸关键点,定义了一些关键点来检测嘴巴、眼睛和鼻尖的状态。
- 通过
// 计算动作状态
const mouthOpen = distance(upperLipBottom, lowerLipTop) > currentObj.threshold.lips;
const leftEyeOpen = distance(leftEyeTop, leftEyeBottom) > currentObj.threshold.eye;
const rightEyeOpen = distance(rightEyeTop, rightEyeBottom) > currentObj.threshold.eye;
const blinked = !(leftEyeOpen && rightEyeOpen);
- 计算动作状态:
mouthOpen
:通过上下唇的距离与设定的阈值currentObj.threshold.lips
比较,判断嘴巴是否张开。leftEyeOpen
和rightEyeOpen
:分别计算左右眼的开合状态。blinked
:如果两只眼睛都闭上,则表示眨眼。
let headShaken = false;
if (appData.lastNoseX !== null) {
let dx = Math.abs(noseTip.x - appData.lastNoseX);
appData.noseXChanges.push(dx);
if (appData.noseXChanges.length > NOSE_X_CHANGE_HISTORY_LENGTH) {
appData.noseXChanges.shift();
const maxChange = Math.max(...appData.noseXChanges);
if (maxChange > currentObj.threshold.headShake) {
headShaken = true;
}
}
}
appData.lastNoseX = noseTip.x;
-
头部摇动检测:
- 记录鼻尖的
x
坐标变化量,用appData.noseXChanges
数组记录变化的历史值。 - 如果变化量历史记录长度超过
NOSE_X_CHANGE_HISTORY_LENGTH
,则移除最旧的记录。 - 判断鼻尖的最大变化量是否超过阈值
currentObj.threshold.headShake
,如果超过,认为进行了头部摇动。
- 记录鼻尖的
// 初始化随机动作顺序
if (!appData.actionsSequence) {
appData.actionsSequence = ["blink", "mouth", "headShake"].sort(() => Math.random() - 0.5);
appData.currentActionIndex = 0;
appData.blinkDetected = false;
appData.mouthDetected = false;
appData.headShakeDetected = false;
}
const currentAction = appData.actionsSequence[appData.currentActionIndex];
- 随机动作顺序 :
actionsSequence
:保存随机排列的动作顺序,包括眨眼、张嘴和摇头。currentActionIndex
:记录当前动作的索引,初始为第一个动作。- 初始化
blinkDetected
、mouthDetected
和headShakeDetected
为false
。
switch (currentAction) {
case "blink":
if (!appData.blinkDetected) {
callBackResult(currentObj, "请眨眨眼");
FaceManager.getInstance().updateMessage(0, "请眨眨眼");
if (blinked) {
appData.blinkDetected = true;
callBackResult(currentObj, "眨眼检测通过");
appData.currentActionIndex++;
}
}
break;
case "mouth":
if (!appData.mouthDetected) {
callBackResult(currentObj, "请张张嘴");
FaceManager.getInstance().updateMessage(0, "请张张嘴");
if (mouthOpen) {
appData.mouthDetected = true;
callBackResult(currentObj, "张嘴检测通过");
appData.currentActionIndex++;
}
}
break;
case "headShake":
if (!appData.headShakeDetected) {
callBackResult(currentObj, "请左右摇头");
FaceManager.getInstance().updateMessage(0, "请左右摇头");
if (headShaken) {
appData.headShakeDetected = true;
callBackResult(currentObj, "摇头检测通过");
appData.currentActionIndex++;
}
}
break;
}
- 动作检测流程:
-
switch
语句根据
currentAction
的值执行相应的检测逻辑:
- blink:检查
blinked
状态。如果检测到眨眼,将blinkDetected
设为true
并调用callBackResult
传递检测结果,然后移动到下一个动作。 - mouth:检查
mouthOpen
状态,如果检测到张嘴,更新mouthDetected
并执行相应操作。 - headShake:检查
headShaken
状态,如果检测到摇头,更新headShakeDetected
并执行相应操作。
- blink:检查
// 检查所有动作是否完成
if (appData.blinkDetected && appData.mouthDetected && appData.headShakeDetected) {
FaceManager.getInstance().updateMessage(0, "通过");
stopRecording(currentObj);
}
};
-
检测结束:
- 如果所有动作(眨眼、张嘴、摇头)都已完成,更新提示为“通过”并调用
stopRecording
结束录制。
- 如果所有动作(眨眼、张嘴、摇头)都已完成,更新提示为“通过”并调用
核心大概执行流程图
更多资源
- 官方文档:faceeffet.com/
- GitHub 开源仓库:github.com/typsusan/ef…
- Gitee 开源仓库:gitee.com/susantyp/ef…